Bug 1507359 Part 3 - Control pausing and resuming in ReplayDebugger, r=lsmyth.
authorBrian Hackett <bhackett1024@gmail.com>
Sat, 17 Nov 2018 07:59:33 -1000
changeset 503368 3a3c453432c15dfcedcb294f2da580506d6de5e7
parent 503367 1c7fc8389e012c987347efefca6b35f3948b742a
child 503369 572f525f1afe920d092830673f843066425d4494
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)
reviewerslsmyth
bugs1507359
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 1507359 Part 3 - Control pausing and resuming in ReplayDebugger, r=lsmyth.
devtools/server/actors/replay/debugger.js
devtools/server/actors/thread.js
--- a/devtools/server/actors/replay/debugger.js
+++ b/devtools/server/actors/replay/debugger.js
@@ -14,25 +14,44 @@
 // methods and properties to those C++ objects. These replay objects are
 // created in the middleman process, and describe things that exist in the
 // recording/replaying process, inspecting them via the RecordReplayControl
 // interface.
 
 "use strict";
 
 const RecordReplayControl = !isWorker && require("RecordReplayControl");
+const Services = require("Services");
 
 ///////////////////////////////////////////////////////////////////////////////
 // ReplayDebugger
 ///////////////////////////////////////////////////////////////////////////////
 
+// Possible preferred directions of travel.
+const Direction = {
+  FORWARD: "FORWARD",
+  BACKWARD: "BACKWARD",
+  NONE: "NONE",
+};
+
 function ReplayDebugger() {
-  RecordReplayControl.registerReplayDebugger(this);
+  const existing = RecordReplayControl.registerReplayDebugger(this);
+  if (existing) {
+    // There is already a ReplayDebugger in existence, use that. There can only
+    // be one ReplayDebugger in the process.
+    return existing;
+  }
 
-  // All breakpoints (per BreakpointPosition) installed by this debugger.
+  // Whether the process is currently paused.
+  this._paused = false;
+
+  // Preferred direction of travel when not explicitly resumed.
+  this._direction = Direction.NONE;
+
+  // All breakpoint positions and handlers installed by this debugger.
   this._breakpoints = [];
 
   // All ReplayDebuggerFramees that have been created while paused at the
   // current position, indexed by their index (zero is the oldest frame, with
   // the index increasing for newer frames). These are invalidated when
   // unpausing.
   this._frames = [];
 
@@ -40,100 +59,326 @@ function ReplayDebugger() {
   // created while paused at the current position, indexed by their id. These
   // are invalidated when unpausing.
   this._objects = [];
 
   // All ReplayDebuggerScripts and ReplayDebuggerScriptSources that have been
   // created, indexed by their id. These stay valid even after unpausing.
   this._scripts = [];
   this._scriptSources = [];
+
+  // How many nested thread-wide paused have been entered.
+  this._threadPauseCount = 0;
+
+  // Flag set if the dispatched _performPause() call can be ignored because the
+  // server entered a thread-wide pause first.
+  this._cancelPerformPause = false;
+
+  // After we are done pausing, callback describing how to resume.
+  this._resumeCallback = null;
+
+  // Handler called when hitting the beginning/end of the recording, or when
+  // a time warp target has been reached.
+  this.replayingOnForcedPause = null;
+
+  // Handler called when the child pauses for any reason.
+  this.replayingOnPositionChange = null;
 }
 
 // Frame index used to refer to the newest frame in the child process.
 const NewestFrameIndex = -1;
 
 ReplayDebugger.prototype = {
 
   /////////////////////////////////////////////////////////
   // General methods
   /////////////////////////////////////////////////////////
 
   replaying: true,
 
   canRewind: RecordReplayControl.canRewind,
-  replayResumeBackward() { RecordReplayControl.resume(/* forward = */ false); },
-  replayResumeForward() { RecordReplayControl.resume(/* forward = */ true); },
-  replayTimeWarp: RecordReplayControl.timeWarp,
-
-  replayPause() {
-    RecordReplayControl.pause();
-    this._repaint();
-  },
 
   replayCurrentExecutionPoint() {
     return this._sendRequest({ type: "currentExecutionPoint" });
   },
 
   replayRecordingEndpoint() {
     return this._sendRequest({ type: "recordingEndpoint" });
   },
 
   replayIsRecording: RecordReplayControl.childIsRecording,
 
   addDebuggee() {},
   removeAllDebuggees() {},
 
   replayingContent(url) {
+    this._ensurePaused();
     return this._sendRequest({ type: "getContent", url });
   },
 
+  // Send a request object to the child process, and synchronously wait for it
+  // to respond.
   _sendRequest(request) {
+    assert(this._paused);
     const data = RecordReplayControl.sendRequest(request);
-    //dump("SendRequest: " +
-    //     JSON.stringify(request) + " -> " + JSON.stringify(data) + "\n");
+    dumpv("SendRequest: " +
+          JSON.stringify(request) + " -> " + JSON.stringify(data));
     if (data.exception) {
       ThrowError(data.exception);
     }
     return data;
   },
 
   // Send a request that requires the child process to perform actions that
   // diverge from the recording. In such cases we want to be interacting with a
   // replaying process (if there is one), as recording child processes won't
   // provide useful responses to such requests.
   _sendRequestAllowDiverge(request) {
+    assert(this._paused);
     RecordReplayControl.maybeSwitchToReplayingChild();
     return this._sendRequest(request);
   },
 
   // Update graphics according to the current state of the child process. This
   // should be done anytime we pause and allow the user to interact with the
   // debugger.
   _repaint() {
     const rv = this._sendRequestAllowDiverge({ type: "repaint" });
     if ("width" in rv && "height" in rv) {
       RecordReplayControl.hadRepaint(rv.width, rv.height);
     } else {
       RecordReplayControl.hadRepaintFailure();
     }
   },
 
+  /////////////////////////////////////////////////////////
+  // Paused/running state
+  /////////////////////////////////////////////////////////
+
+  // Paused State Management
+  //
+  // The single ReplayDebugger is exclusively responsible for controlling the
+  // position of the child process by keeping track of when it pauses and
+  // sending it commands to resume.
+  //
+  // The general goal of controlling this position is to make the child process
+  // execute at predictable times, similar to how it would execute if the
+  // debuggee was in the same process as this one (as is the case when not
+  // replaying), as described below:
+  //
+  // - After the child pauses, the it will only resume executing when an event
+  //   loop is running that is *not* associated with the thread actor's nested
+  //   pauses. As long as the thread actor has pushed a pause, the child will
+  //   remain paused.
+  //
+  // - After the child resumes, installed breakpoint handlers will only execute
+  //   when an event loop is running (which, because of the above point, cannot
+  //   be associated with a thread actor's nested pause).
+
+  replayResumeBackward() { this._resume(/* forward = */ false); },
+  replayResumeForward() { this._resume(/* forward = */ true); },
+
+  _resume(forward) {
+    this._ensurePaused();
+    this._setResume(() => {
+      this._paused = false;
+      this._direction = forward ? Direction.FORWARD : Direction.BACKWARD;
+      dumpv("Resuming " + this._direction);
+      RecordReplayControl.resume(forward);
+      if (this._paused) {
+        // If we resume and immediately pause, we are at an endpoint of the
+        // recording. Force the thread to pause.
+        this.replayingOnForcedPause(this.getNewestFrame());
+      }
+    });
+  },
+
+  replayTimeWarp(target) {
+    this._ensurePaused();
+    this._setResume(() => {
+      this._paused = false;
+      this._direction = Direction.NONE;
+      dumpv("Warping " + JSON.stringify(target));
+      RecordReplayControl.timeWarp(target);
+
+      // timeWarp() doesn't return until the child has reached the target of
+      // the warp, after which we force the thread to pause.
+      assert(this._paused);
+      this.replayingOnForcedPause(this.getNewestFrame());
+    });
+  },
+
+  replayPause() {
+    this._ensurePaused();
+
+    // Cancel any pending resume.
+    this._resumeCallback = null;
+  },
+
+  _ensurePaused() {
+    if (!this._paused) {
+      RecordReplayControl.waitUntilPaused();
+      assert(this._paused);
+    }
+  },
+
+  // This hook is called whenever the child has paused, which can happen
+  // within a RecordReplayControl method (resume, timeWarp, waitUntilPaused) or
+  // or be delivered via the event loop.
+  _onPause() {
+    this._paused = true;
+
+    // The position change handler is always called on pause notifications.
+    if (this.replayingOnPositionChange) {
+      this.replayingOnPositionChange();
+    }
+
+    // Call _performPause() soon via the event loop to check for breakpoint
+    // handlers at this point.
+    this._cancelPerformPause = false;
+    Services.tm.dispatchToMainThread(this._performPause.bind(this));
+  },
+
+  _performPause() {
+    // The child paused at some time in the past and any breakpoint handlers
+    // may still need to be called. If we've entered a thread-wide pause or
+    // have already told the child to resume, don't call handlers.
+    if (!this._paused || this._cancelPerformPause || this._resumeCallback) {
+      return;
+    }
+
+    const point = this.replayCurrentExecutionPoint();
+    dumpv("PerformPause " + JSON.stringify(point));
+
+    if (point.position.kind == "Invalid") {
+      // We paused at a checkpoint, and there are no handlers to call.
+    } else {
+      // Call any handlers for this point, unless one resumes execution.
+      for (const { handler, position } of this._breakpoints) {
+        if (RecordReplayControl.positionSubsumes(position, point.position)) {
+          handler();
+          assert(!this._threadPauseCount);
+          if (this._resumeCallback) {
+            break;
+          }
+        }
+      }
+    }
+
+    // If no handlers entered a thread-wide pause (resetting this._direction)
+    // or gave an explicit resume, continue traveling in the same direction
+    // we were going when we paused.
+    assert(!this._threadPauseCount);
+    if (!this._resumeCallback) {
+      switch (this._direction) {
+      case Direction.FORWARD: this.replayResumeForward(); break;
+      case Direction.BACKWARD: this.replayResumeBackward(); break;
+      }
+    }
+  },
+
+  // This hook is called whenever we switch between recording and replaying
+  // child processes.
+  _onSwitchChild() {
+    // The position change handler listens to changes to the current child.
+    if (this.replayingOnPositionChange) {
+      // Children are paused whenever we switch between them.
+      const paused = this._paused;
+      this._paused = true;
+      this.replayingOnPositionChange();
+      this._paused = paused;
+    }
+  },
+
+  replayPushThreadPause() {
+    // The thread has paused so that the user can interact with it. The child
+    // will stay paused until this thread-wide pause has been popped.
+    assert(this._paused);
+    assert(!this._resumeCallback);
+    if (++this._threadPauseCount == 1) {
+      // Save checkpoints near the current position in case the user rewinds.
+      RecordReplayControl.markExplicitPause();
+
+      // There is no preferred direction of travel after an explicit pause.
+      this._direction = Direction.NONE;
+
+      // Update graphics according to the current state of the child.
+      this._repaint();
+
+      // If breakpoint handlers for the pause haven't been called yet, don't
+      // call them at all.
+      this._cancelPerformPause = true;
+    }
+    const point = this.replayCurrentExecutionPoint();
+    dumpv("PushPause " + JSON.stringify(point));
+  },
+
+  replayPopThreadPause() {
+    dumpv("PopPause");
+
+    // After popping the last thread-wide pause, the child can resume.
+    if (--this._threadPauseCount == 0 && this._resumeCallback) {
+      Services.tm.dispatchToMainThread(this._performResume.bind(this));
+    }
+  },
+
+  _setResume(callback) {
+    assert(this._paused);
+
+    // Overwrite any existing resume direction.
+    this._resumeCallback = callback;
+
+    // The child can resume immediately if there is no thread-wide pause.
+    if (!this._threadPauseCount) {
+      Services.tm.dispatchToMainThread(this._performResume.bind(this));
+    }
+  },
+
+  _performResume() {
+    assert(this._paused && !this._threadPauseCount);
+    if (this._resumeCallback && !this._threadPauseCount) {
+      const callback = this._resumeCallback;
+      this._invalidateAfterUnpause();
+      this._resumeCallback = null;
+      callback();
+    }
+  },
+
+  // Clear out all data that becomes invalid when the child unpauses.
+  _invalidateAfterUnpause() {
+    this._frames.forEach(frame => frame._invalidate());
+    this._frames.length = 0;
+
+    this._objects.forEach(obj => obj._invalidate());
+    this._objects.length = 0;
+  },
+
+  /////////////////////////////////////////////////////////
+  // Breakpoint management
+  /////////////////////////////////////////////////////////
+
   _setBreakpoint(handler, position, data) {
-    const id = RecordReplayControl.setBreakpoint(handler, position);
-    this._breakpoints.push({id, position, data});
+    this._ensurePaused();
+    dumpv("AddBreakpoint " + JSON.stringify(position));
+    RecordReplayControl.addBreakpoint(position);
+    this._breakpoints.push({handler, position, data});
   },
 
   _clearMatchingBreakpoints(callback) {
-    this._breakpoints = this._breakpoints.filter(breakpoint => {
-      if (callback(breakpoint)) {
-        RecordReplayControl.clearBreakpoint(breakpoint.id);
-        return false;
+    this._ensurePaused();
+    const newBreakpoints = this._breakpoints.filter(bp => !callback(bp));
+    if (newBreakpoints.length != this._breakpoints.length) {
+      dumpv("ClearBreakpoints");
+      RecordReplayControl.clearBreakpoints();
+      for (const { position } of newBreakpoints) {
+        dumpv("AddBreakpoint " + JSON.stringify(position));
+        RecordReplayControl.addBreakpoint(position);
       }
-      return true;
-    });
+    }
+    this._breakpoints = newBreakpoints;
   },
 
   _searchBreakpoints(callback) {
     for (const breakpoint of this._breakpoints) {
       const v = callback(breakpoint);
       if (v) {
         return v;
       }
@@ -152,26 +397,16 @@ ReplayDebugger.prototype = {
   _breakpointKindSetter(kind, handler, callback) {
     if (handler) {
       this._setBreakpoint(callback, { kind }, handler);
     } else {
       this._clearMatchingBreakpoints(({position}) => position.kind == kind);
     }
   },
 
-  // This is called on all ReplayDebuggers whenever the child process is about
-  // to unpause. Clear out all data that is invalidated as a result.
-  invalidateAfterUnpause() {
-    this._frames.forEach(frame => frame._invalidate());
-    this._frames.length = 0;
-
-    this._objects.forEach(obj => obj._invalidate());
-    this._objects.length = 0;
-  },
-
   /////////////////////////////////////////////////////////
   // Script methods
   /////////////////////////////////////////////////////////
 
   _getScript(id) {
     if (!id) {
       return null;
     }
@@ -206,16 +441,17 @@ ReplayDebugger.prototype = {
     const data = this._sendRequest({
       type: "findScripts",
       query: this._convertScriptQuery(query),
     });
     return data.map(script => this._addScript(script));
   },
 
   findAllConsoleMessages() {
+    this._ensurePaused();
     const messages = this._sendRequest({ type: "findConsoleMessages" });
     return messages.map(this._convertConsoleMessage.bind(this));
   },
 
   /////////////////////////////////////////////////////////
   // ScriptSource methods
   /////////////////////////////////////////////////////////
 
@@ -230,16 +466,17 @@ ReplayDebugger.prototype = {
   _addSource(data) {
     if (!this._scriptSources[data.id]) {
       this._scriptSources[data.id] = new ReplayDebuggerScriptSource(this, data);
     }
     return this._scriptSources[data.id];
   },
 
   findSources() {
+    this._ensurePaused();
     const data = this._sendRequest({ type: "findSources" });
     return data.map(source => this._addSource(source));
   },
 
   /////////////////////////////////////////////////////////
   // Object methods
   /////////////////////////////////////////////////////////
 
@@ -358,55 +595,36 @@ ReplayDebugger.prototype = {
   set onNewScript(handler) {
     this._breakpointKindSetter("NewScript", handler,
                                () => handler.call(this, this._getNewScript()));
   },
 
   get onEnterFrame() { return this._breakpointKindGetter("EnterFrame"); },
   set onEnterFrame(handler) {
     this._breakpointKindSetter("EnterFrame", handler,
-                               () => { this._repaint();
-                                       handler.call(this, this.getNewestFrame()); });
+                               () => { handler.call(this, this.getNewestFrame()); });
   },
 
   get replayingOnPopFrame() {
     return this._searchBreakpoints(({position, data}) => {
       return (position.kind == "OnPop" && !position.script) ? data : null;
     });
   },
 
   set replayingOnPopFrame(handler) {
     if (handler) {
-      this._setBreakpoint(() => { this._repaint();
-                                  handler.call(this, this.getNewestFrame()); },
+      this._setBreakpoint(() => { handler.call(this, this.getNewestFrame()); },
                           { kind: "OnPop" }, handler);
     } else {
       this._clearMatchingBreakpoints(({position}) => {
         return position.kind == "OnPop" && !position.script;
       });
     }
   },
 
-  get replayingOnForcedPause() {
-    return this._breakpointKindGetter("ForcedPause");
-  },
-  set replayingOnForcedPause(handler) {
-    this._breakpointKindSetter("ForcedPause", handler,
-                               () => { this._repaint();
-                                       handler.call(this, this.getNewestFrame()); });
-  },
-
-  get replayingOnPositionChange() {
-    return this._breakpointKindGetter("PositionChange");
-  },
-  set replayingOnPositionChange(handler) {
-    this._breakpointKindSetter("PositionChange", handler,
-                               () => { handler.call(this); });
-  },
-
   getNewConsoleMessage() {
     const message = this._sendRequest({ type: "getNewConsoleMessage" });
     return this._convertConsoleMessage(message);
   },
 
   get onConsoleMessage() {
     return this._breakpointKindGetter("ConsoleMessage");
   },
@@ -442,18 +660,17 @@ ReplayDebuggerScript.prototype = {
   },
 
   getLineOffsets(line) { return this._forward("getLineOffsets", line); },
   getOffsetLocation(pc) { return this._forward("getOffsetLocation", pc); },
   getSuccessorOffsets(pc) { return this._forward("getSuccessorOffsets", pc); },
   getPredecessorOffsets(pc) { return this._forward("getPredecessorOffsets", pc); },
 
   setBreakpoint(offset, handler) {
-    this._dbg._setBreakpoint(() => { this._dbg._repaint();
-                                     handler.hit(this._dbg.getNewestFrame()); },
+    this._dbg._setBreakpoint(() => { handler.hit(this._dbg.getNewestFrame()); },
                              { kind: "Break", script: this._data.id, offset },
                              handler);
   },
 
   clearBreakpoint(handler) {
     this._dbg._clearMatchingBreakpoints(({position, data}) => {
       return position.script == this._data.id && handler == data;
     });
@@ -560,18 +777,17 @@ ReplayDebuggerFrame.prototype = {
       ({position}) => this._positionMatches(position, "OnStep")
     );
   },
 
   setReplayingOnStep(handler, offsets) {
     this._clearOnStepBreakpoints();
     offsets.forEach(offset => {
       this._dbg._setBreakpoint(
-        () => { this._dbg._repaint();
-                handler.call(this._dbg.getNewestFrame()); },
+        () => { handler.call(this._dbg.getNewestFrame()); },
         { kind: "OnStep",
           script: this._data.script,
           offset,
           frameIndex: this._data.index },
         handler);
     });
   },
 
@@ -579,17 +795,16 @@ ReplayDebuggerFrame.prototype = {
     return this._dbg._searchBreakpoints(({position, data}) => {
       return this._positionMatches(position, "OnPop") ? data : null;
     });
   },
 
   set onPop(handler) {
     if (handler) {
       this._dbg._setBreakpoint(() => {
-          this._dbg._repaint();
           const result = this._dbg._sendRequest({ type: "popFrameResult" });
           handler.call(this._dbg.getNewestFrame(),
                        this._dbg._convertCompletionValue(result));
         },
         { kind: "OnPop", script: this._data.script, frameIndex: this._data.index },
         handler);
     } else {
       this._dbg._clearMatchingBreakpoints(
@@ -780,16 +995,20 @@ ReplayDebuggerEnvironment.prototype = {
   find: NYI,
   setVariable: NotAllowed,
 };
 
 ///////////////////////////////////////////////////////////////////////////////
 // Utilities
 ///////////////////////////////////////////////////////////////////////////////
 
+function dumpv(str) {
+  //dump("[ReplayDebugger] " + str + "\n");
+}
+
 function NYI() {
   ThrowError("Not yet implemented");
 }
 
 function NotAllowed() {
   ThrowError("Not allowed");
 }
 
@@ -797,17 +1016,17 @@ function ThrowError(msg)
 {
   const error = new Error(msg);
   dump("ReplayDebugger Server Error: " + msg + " Stack: " + error.stack + "\n");
   throw error;
 }
 
 function assert(v) {
   if (!v) {
-    throw new Error("Assertion Failed!");
+    ThrowError("Assertion Failed!");
   }
 }
 
 function isNonNullObject(obj) {
   return obj && (typeof obj == "object" || typeof obj == "function");
 }
 
 module.exports = ReplayDebugger;
--- a/devtools/server/actors/thread.js
+++ b/devtools/server/actors/thread.js
@@ -109,18 +109,24 @@ 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);
+        const sendProgress = throttle((recording, executionPoint) => {
+          if (this.attached) {
+            this.conn.send({ type: "progress", from: this.actorID,
+                             recording, executionPoint });
+          }
+        }, 100);
         this._dbg.replayingOnPositionChange =
-          throttle(this.replayingOnPositionChange.bind(this), 100);
+          this.replayingOnPositionChange.bind(this, sendProgress);
       }
       // Keep the debugger disabled until a client attaches.
       this._dbg.enabled = this._state != "detached";
     }
     return this._dbg;
   },
 
   get globalDebugObject() {
@@ -175,27 +181,33 @@ const ThreadActor = ActorClassWithSpec(t
   /**
    * Keep track of all of the nested event loops we use to pause the debuggee
    * when we hit a breakpoint/debugger statement/etc in one place so we can
    * resolve them when we get resume packets. We have more than one (and keep
    * them in a stack) because we can pause within client evals.
    */
   _threadPauseEventLoops: null,
   _pushThreadPause: function() {
+    if (this.dbg.replaying) {
+      this.dbg.replayPushThreadPause();
+    }
     if (!this._threadPauseEventLoops) {
       this._threadPauseEventLoops = [];
     }
     const eventLoop = this._nestedEventLoops.push();
     this._threadPauseEventLoops.push(eventLoop);
     eventLoop.enter();
   },
   _popThreadPause: function() {
     const eventLoop = this._threadPauseEventLoops.pop();
     assert(eventLoop, "Should have an event loop.");
     eventLoop.resolve();
+    if (this.dbg.replaying) {
+      this.dbg.replayPopThreadPause();
+    }
   },
 
   /**
    * Remove all debuggees and clear out the thread's sources.
    */
   clearDebuggees: function() {
     if (this._dbg) {
       this.dbg.removeAllDebuggees();
@@ -1781,20 +1793,20 @@ const ThreadActor = ActorClassWithSpec(t
     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() {
+  replayingOnPositionChange: function(sendProgress) {
     const recording = this.dbg.replayIsRecording();
     const executionPoint = this.dbg.replayCurrentExecutionPoint();
-    this.conn.send({ type: "progress", from: this.actorID, recording, executionPoint });
+    sendProgress(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