Bug 1504222 - Notify the client when devtools goes from replaying to recording. r=dwalsh
authorJason Laster <jlaster@mozilla.com>
Sun, 11 Nov 2018 12:42:33 -0500
changeset 503209 95c0c22616c161aff925f1ddc124f0da40561905
parent 503206 6c28ad7f98a7629f5a1b2f123aaad66f1f6233f0
child 503210 91b2fc1a6529b25ff73b3c2a14d7add667057155
push id10290
push userffxbld-merge
push dateMon, 03 Dec 2018 16:23:23 +0000
treeherdermozilla-beta@700bed2445e6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdwalsh
bugs1504222
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1504222 - Notify the client when devtools goes from replaying to recording. r=dwalsh Tags: Bug #: 1504222 Differential Revision: https://phabricator.services.mozilla.com/D11593
devtools/client/framework/attach-thread.js
devtools/client/themes/toolbox.css
devtools/client/themes/webconsole.css
devtools/client/webconsole/components/ConsoleOutput.js
devtools/client/webconsole/components/MessageContainer.js
devtools/client/webconsole/webconsole-output-wrapper.js
devtools/client/webreplay/components/WebReplayPlayer.js
devtools/server/actors/thread.js
devtools/shared/client/thread-client.js
--- a/devtools/client/framework/attach-thread.js
+++ b/devtools/client/framework/attach-thread.js
@@ -16,21 +16,16 @@ function handleThreadState(toolbox, even
     return;
   }
 
   // TODO: Bug 1225492, we continue emitting events on the target
   // like we used to, but we should emit these only on the
   // threadClient now.
   toolbox.target.emit("thread-" + event);
 
-  const replayButton = toolbox.doc.getElementById("command-button-stop-replay");
-  if (replayButton) {
-    replayButton.classList.toggle("paused", event === "paused");
-  }
-
   if (event === "paused") {
     toolbox.highlightTool("jsdebugger");
 
     if (packet.why.type === "debuggerStatement" ||
        packet.why.type === "breakpoint" ||
        packet.why.type === "exception") {
       toolbox.raise();
       toolbox.selectTool("jsdebugger", packet.why.type);
@@ -54,16 +49,23 @@ function attachThread(toolbox) {
       if (res.error) {
         reject(new Error("Couldn't attach to thread: " + res.error));
         return;
       }
 
       threadClient.addListener("paused", handleThreadState.bind(null, toolbox));
       threadClient.addListener("resumed", handleThreadState.bind(null, toolbox));
 
+      threadClient.addListener("progress", (_, {recording}) => {
+        const replayButton = toolbox.doc.getElementById("command-button-stop-replay");
+        if (replayButton) {
+          replayButton.classList.toggle("recording", recording);
+        }
+      });
+
       if (!threadClient.paused) {
         reject(new Error("Thread in wrong state when starting up, should be paused"));
       }
 
       // These flags need to be set here because the client sends them
       // with the `resume` request. We make sure to do this before
       // resuming to avoid another interrupt. We can't pass it in with
       // `threadOptions` because the resume request will override them.
--- a/devtools/client/themes/toolbox.css
+++ b/devtools/client/themes/toolbox.css
@@ -328,21 +328,21 @@
   background-color: transparent;
 }
 
 #command-button-replay:hover, #command-button-stop-replay:hover {
   background: var(--toolbarbutton-background);
 }
 
 #command-button-stop-replay::before {
-  fill: var(--red-60);
+  fill: currentColor;
 }
 
-#command-button-stop-replay.paused::before{
-  fill: currentColor;
+#command-button-stop-replay.recording::before{
+  fill: var(--red-60);
 }
 
 #command-button-scratchpad::before {
   background-image: var(--command-scratchpad-image);
 }
 
 #command-button-eyedropper::before {
   background-image: var(--command-eyedropper-image);
@@ -487,16 +487,20 @@
 .webreplay-player .btn {
   width: 15px;
   height: 19px;
   background: #6A6A6A;
   mask-size: 15px 19px;
   align-self: center;
 }
 
+.webreplay-player .command-button.active:hover {
+  background: none;
+}
+
 .webreplay-player .play-button {
   mask-image: var(--play-image);
   margin-right: 5px;
   margin-left: 2px;
 }
 
 .webreplay-player .rewind-button {
   mask-image: var(--play-image);
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -110,17 +110,17 @@ a {
   background-color: var(--warning-background-color);
 }
 
 .message.paused::before {
   background: #d8461f;
   opacity: 0.6;
   width: 100vw;
   height: 1px;
-  bottom: 0px;
+  top: 0px;
   left: -3px;
   display: block;
   content: "";
   position: absolute;
 }
 
 .message.paused ~ .message {
   opacity: 0.5;
--- a/devtools/client/webconsole/components/ConsoleOutput.js
+++ b/devtools/client/webconsole/components/ConsoleOutput.js
@@ -21,16 +21,35 @@ const {
 const MessageContainer = createFactory(require("devtools/client/webconsole/components/MessageContainer").MessageContainer);
 const {
   MESSAGE_TYPE,
 } = require("devtools/client/webconsole/constants");
 const {
   getInitialMessageCountForViewport,
 } = require("devtools/client/webconsole/utils/messages.js");
 
+// Finds the message that comes right after the current paused execution point.
+// NOTE: visibleMessages are not guaranteed to be ordered.
+function getPausedMessage(visibleMessages, messages, executionPoint) {
+  if (!executionPoint || !visibleMessages) {
+    return null;
+  }
+
+  let pausedMessage = messages.get(visibleMessages[0]);
+  for (const messageId of visibleMessages) {
+    const message = messages.get(messageId);
+    if (executionPoint.progress >= message.executionPoint.progress &&
+        message.executionPoint.progress > pausedMessage.executionPoint.progress) {
+      pausedMessage = message;
+    }
+  }
+
+  return pausedMessage;
+}
+
 class ConsoleOutput extends Component {
   static get propTypes() {
     return {
       initialized: PropTypes.bool.isRequired,
       messages: PropTypes.object.isRequired,
       messagesUi: PropTypes.array.isRequired,
       serviceContainer: PropTypes.shape({
         attachRefToHud: PropTypes.func.isRequired,
@@ -140,29 +159,33 @@ class ConsoleOutput extends Component {
     if (!initialized) {
       const numberMessagesFitViewport = getInitialMessageCountForViewport(window);
       if (numberMessagesFitViewport < visibleMessages.length) {
         visibleMessages = visibleMessages.slice(
           visibleMessages.length - numberMessagesFitViewport);
       }
     }
 
+    const pausedMessage = getPausedMessage(
+      visibleMessages, messages, pausedExecutionPoint);
+
     const messageNodes = visibleMessages.map((messageId) => MessageContainer({
       dispatch,
       key: messageId,
       messageId,
       serviceContainer,
       open: messagesUi.includes(messageId),
       tableData: messagesTableData.get(messageId),
       timestampsVisible,
       repeat: messagesRepeat[messageId],
       networkMessageUpdate: networkMessagesUpdate[messageId],
       networkMessageActiveTabId,
       pausedExecutionPoint,
       getMessage: () => messages.get(messageId),
+      isPaused: pausedMessage && pausedMessage.id == messageId,
     }));
 
     return (
       dom.div({
         className: "webconsole-output",
         onContextMenu: this.onContextMenu,
         ref: node => {
           this.outputNode = node;
--- a/devtools/client/webconsole/components/MessageContainer.js
+++ b/devtools/client/webconsole/components/MessageContainer.js
@@ -19,34 +19,28 @@ const componentMap = new Map([
   ["ConsoleApiCall", require("./message-types/ConsoleApiCall")],
   ["ConsoleCommand", require("./message-types/ConsoleCommand")],
   ["DefaultRenderer", require("./message-types/DefaultRenderer")],
   ["EvaluationResult", require("./message-types/EvaluationResult")],
   ["NetworkEventMessage", require("./message-types/NetworkEventMessage")],
   ["PageError", require("./message-types/PageError")],
 ]);
 
-function isPaused({ getMessage, pausedExecutionPoint }) {
-  const message = getMessage();
-  return pausedExecutionPoint
-  && message.executionPoint
-    && pausedExecutionPoint.progress === message.executionPoint.progress;
-}
-
 class MessageContainer extends Component {
   static get propTypes() {
     return {
       messageId: PropTypes.string.isRequired,
       open: PropTypes.bool.isRequired,
       serviceContainer: PropTypes.object.isRequired,
       tableData: PropTypes.object,
       timestampsVisible: PropTypes.bool.isRequired,
       repeat: PropTypes.number,
       networkMessageUpdate: PropTypes.object,
       getMessage: PropTypes.func.isRequired,
+      isPaused: PropTypes.bool.isRequired,
     };
   }
 
   static get defaultProps() {
     return {
       open: false,
     };
   }
@@ -54,33 +48,31 @@ class MessageContainer extends Component
   shouldComponentUpdate(nextProps, nextState) {
     const repeatChanged = this.props.repeat !== nextProps.repeat;
     const openChanged = this.props.open !== nextProps.open;
     const tableDataChanged = this.props.tableData !== nextProps.tableData;
     const timestampVisibleChanged =
       this.props.timestampsVisible !== nextProps.timestampsVisible;
     const networkMessageUpdateChanged =
       this.props.networkMessageUpdate !== nextProps.networkMessageUpdate;
-    const pausedChanged = isPaused(this.props) !== isPaused(nextProps);
+    const pausedChanged = this.props.isPaused !== nextProps.isPaused;
 
     return repeatChanged
       || openChanged
       || tableDataChanged
       || timestampVisibleChanged
       || networkMessageUpdateChanged
       || pausedChanged;
   }
 
   render() {
     const message = this.props.getMessage();
 
     const MessageComponent = getMessageComponent(message);
-    return MessageComponent(Object.assign({message}, this.props, {
-      isPaused: isPaused(this.props),
-    }));
+    return MessageComponent(Object.assign({message}, this.props));
   }
 }
 
 function getMessageComponent(message) {
   if (!message) {
     return componentMap.get("DefaultRenderer");
   }
 
--- a/devtools/client/webconsole/webconsole-output-wrapper.js
+++ b/devtools/client/webconsole/webconsole-output-wrapper.js
@@ -170,17 +170,18 @@ WebConsoleOutputWrapper.prototype = {
         menu.once("open", () => this.emit("menu-open"));
         menu.popup(screenX, screenY, { doc: this.owner.chromeWindow.document });
 
         return menu;
       };
 
       if (this.toolbox) {
         this.toolbox.threadClient.addListener("paused", this.dispatchPaused.bind(this));
-        this.toolbox.threadClient.addListener("resumed", this.dispatchResumed.bind(this));
+        this.toolbox.threadClient.addListener(
+          "progress", this.dispatchProgress.bind(this));
 
         Object.assign(serviceContainer, {
           onViewSourceInDebugger: frame => {
             this.toolbox.viewSourceInDebugger(frame.url, frame.line).then(() => {
               this.telemetry.recordEvent("jump_to_source", "webconsole",
                                          null, { "session_id": this.toolbox.sessionId }
               );
               this.hud.emit("source-in-debugger-opened");
@@ -360,18 +361,20 @@ WebConsoleOutputWrapper.prototype = {
   },
 
   dispatchPaused: function(_, packet) {
     if (packet.executionPoint) {
       store.dispatch(actions.setPauseExecutionPoint(packet.executionPoint));
     }
   },
 
-  dispatchResumed: function(_, packet) {
-    store.dispatch(actions.setPauseExecutionPoint(null));
+  dispatchProgress: function(_, packet) {
+    const {executionPoint, recording} = packet;
+    const point = recording ? null : executionPoint;
+    store.dispatch(actions.setPauseExecutionPoint(point));
   },
 
   dispatchMessageUpdate: function(message, res) {
     // network-message-updated will emit when all the update message arrives.
     // Since we can't ensure the order of the network update, we check
     // that networkInfo.updates has all we need.
     // Note that 'requestPostData' is sent only for POST requests, so we need
     // to count with that.
--- a/devtools/client/webreplay/components/WebReplayPlayer.js
+++ b/devtools/client/webreplay/components/WebReplayPlayer.js
@@ -16,130 +16,163 @@ class WebReplayPlayer extends Component 
   }
 
   constructor(props) {
     super(props);
     this.state = {
       executionPoint: null,
       recordingEndpoint: null,
       seeking: false,
+      recording: true,
       messages: [],
     };
   }
 
   componentDidMount() {
     this.threadClient.addListener("paused", this.onPaused.bind(this));
     this.threadClient.addListener("resumed", this.onResumed.bind(this));
+    this.threadClient.addListener("progress", this.onProgress.bind(this));
     this.activeConsole._client.addListener(
       "consoleAPICall",
       this.onMessage.bind(this)
     );
   }
 
   get threadClient() {
     return this.props.toolbox.threadClient;
   }
 
   get activeConsole() {
     return this.props.toolbox.target.activeConsole;
   }
 
+  isRecording() {
+    return this.state.recording;
+  }
+
+  isReplaying() {
+    const {recording} = this.state;
+    return !this.isPaused() && !recording;
+  }
+
   isPaused() {
-    const { executionPoint, seeking } = this.state;
-    return !!executionPoint || !!seeking;
+    const { paused } = this.state;
+    return paused;
   }
 
   onPaused(_, packet) {
     if (packet && packet.recordingEndpoint) {
       const { executionPoint, recordingEndpoint } = packet;
-      this.setState({ executionPoint, recordingEndpoint, seeking: false });
+      this.setState({ executionPoint, recordingEndpoint, paused: true });
     }
   }
 
   onResumed(_, packet) {
-    this.setState({ executionPoint: null });
+    this.setState({ paused: false });
+  }
+
+  onProgress(_, packet) {
+    const { recording, executionPoint } = packet;
+    this.setState({ recording, executionPoint });
   }
 
   onMessage(_, packet) {
     this.setState({ messages: this.state.messages.concat(packet.message) });
   }
 
   seek(executionPoint) {
     if (!executionPoint) {
       return null;
     }
 
     // set seeking to the current execution point to avoid a progress bar jump
-    this.setState({ seeking: this.state.executionPoint });
     return this.threadClient.timeWarp(executionPoint);
   }
 
   next(ev) {
+    if (!this.isPaused()) {
+      return null;
+    }
+
     if (!ev.metaKey) {
       return this.threadClient.resume();
     }
 
-    const { messages, executionPoint } = this.state;
-    const seekPoint = messages
+    const { messages, executionPoint, recordingEndpoint } = this.state;
+    let seekPoint = messages
       .map(m => m.executionPoint)
       .filter(point => point.progress > executionPoint.progress)
       .slice(0)[0];
 
+    if (!seekPoint) {
+      seekPoint = recordingEndpoint;
+    }
+
     return this.seek(seekPoint);
   }
 
   previous(ev) {
+    if (!this.isPaused()) {
+      return null;
+    }
+
     if (!ev.metaKey) {
       return this.threadClient.rewind();
     }
 
     const { messages, executionPoint } = this.state;
 
     const seekPoint = messages
       .map(m => m.executionPoint)
       .filter(point => point.progress < executionPoint.progress)
       .slice(-1)[0];
 
     return this.seek(seekPoint);
   }
 
   renderCommands() {
-    if (this.isPaused()) {
+    if (this.isRecording()) {
       return [
         div(
           { className: "command-button" },
           div({
-            className: "rewind-button btn",
-            onClick: ev => this.previous(ev),
-          })
-        ),
-        div(
-          { className: "command-button" },
-          div({
-            className: "play-button btn",
-            onClick: ev => this.next(ev),
+            className: "pause-button btn",
+            onClick: () => this.threadClient.interrupt(),
           })
         ),
       ];
     }
 
+    const isActiveClass = !this.isPaused() ? "active" : "";
+
     return [
       div(
-        { className: "command-button" },
+        { className: `command-button ${isActiveClass}` },
         div({
-          className: "pause-button btn",
-          onClick: () => this.threadClient.interrupt(),
+          className: "rewind-button btn",
+          onClick: ev => this.previous(ev),
+        })
+      ),
+      div(
+        { className: `command-button ${isActiveClass}` },
+        div({
+          className: "play-button btn",
+          onClick: ev => this.next(ev),
         })
       ),
     ];
   }
 
   renderMessages() {
     const messages = this.state.messages;
 
+    if (this.isRecording()) {
+      return [];
+    }
+
     return messages.map((message, index) =>
       dom.div({
         className: "message",
         style: {
           left: `${this.getPercent(message.executionPoint)}%`,
         },
         onClick: () => this.seek(message.executionPoint),
       })
@@ -164,19 +197,17 @@ class WebReplayPlayer extends Component 
         div(
           { className: "overlay-container " },
           div({ className: "commands" }, ...this.renderCommands()),
           div(
             { className: "progressBar" },
             div({
               className: "progress",
               style: {
-                width: `${this.getPercent(
-                  this.state.executionPoint || this.state.seeking
-                )}%`,
+                width: `${this.getPercent(this.state.executionPoint)}%`,
               },
             }),
             ...this.renderMessages()
           )
         )
       )
     );
   }
--- a/devtools/server/actors/thread.js
+++ b/devtools/server/actors/thread.js
@@ -23,16 +23,17 @@ loader.lazyRequireGetter(this, "Breakpoi
 loader.lazyRequireGetter(this, "setBreakpointAtEntryPoints", "devtools/server/actors/breakpoint", true);
 loader.lazyRequireGetter(this, "EnvironmentActor", "devtools/server/actors/environment", true);
 loader.lazyRequireGetter(this, "SourceActorStore", "devtools/server/actors/utils/source-actor-store", true);
 loader.lazyRequireGetter(this, "BreakpointActorMap", "devtools/server/actors/utils/breakpoint-actor-map", true);
 loader.lazyRequireGetter(this, "PauseScopedObjectActor", "devtools/server/actors/pause-scoped", true);
 loader.lazyRequireGetter(this, "EventLoopStack", "devtools/server/actors/utils/event-loop", true);
 loader.lazyRequireGetter(this, "FrameActor", "devtools/server/actors/frame", true);
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
+loader.lazyRequireGetter(this, "throttle", "devtools/shared/throttle", true);
 
 /**
  * JSD2 actors.
  */
 
 /**
  * Creates a ThreadActor.
  *
@@ -108,16 +109,18 @@ const ThreadActor = ActorClassWithSpec(t
   get dbg() {
     if (!this._dbg) {
       this._dbg = this._parent.makeDebugger();
       this._dbg.uncaughtExceptionHook = this.uncaughtExceptionHook;
       this._dbg.onDebuggerStatement = this.onDebuggerStatement;
       this._dbg.onNewScript = this.onNewScript;
       if (this._dbg.replaying) {
         this._dbg.replayingOnForcedPause = this.replayingOnForcedPause.bind(this);
+        this._dbg.replayingOnPositionChange =
+          throttle(this.replayingOnPositionChange.bind(this), 100);
       }
       // Keep the debugger disabled until a client attaches.
       this._dbg.enabled = this._state != "detached";
     }
     return this._dbg;
   },
 
   get globalDebugObject() {
@@ -1773,16 +1776,27 @@ const ThreadActor = ActorClassWithSpec(t
     return this._pauseAndRespond(frame, { type: "debuggerStatement" });
   },
 
   onSkipBreakpoints: function({ skip }) {
     this.skipBreakpoints = skip;
     return { skip };
   },
 
+  /*
+   * A function that the engine calls when a recording/replaying process has
+   * changed its position: a checkpoint was reached or a switch between a
+   * recording and replaying child process occurred.
+   */
+  replayingOnPositionChange: function() {
+    const recording = this.dbg.replayIsRecording();
+    const executionPoint = this.dbg.replayCurrentExecutionPoint();
+    this.conn.send({ type: "progress", from: this.actorID, recording, executionPoint });
+  },
+
   /**
    * A function that the engine calls when replay has hit a point where it will
    * pause, even if no breakpoint has been set. Such points include hitting the
    * beginning or end of the replay, or reaching the target of a time warp.
    *
    * @param frame Debugger.Frame
    *        The youngest stack frame, or null.
    */
--- a/devtools/shared/client/thread-client.js
+++ b/devtools/shared/client/thread-client.js
@@ -749,14 +749,14 @@ ThreadClient.prototype = {
    * @param actors [string]
    *        List of actor ID of the queried objects.
    */
   getPrototypesAndProperties: DebuggerClient.requester({
     type: "prototypesAndProperties",
     actors: arg(0),
   }),
 
-  events: ["newSource"],
+  events: ["newSource", "progress"],
 };
 
 eventSource(ThreadClient.prototype);
 
 module.exports = ThreadClient;