From a8ef006ceba5b9ff2345beec4ef51a00078d551f Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Tue, 4 Jun 2019 10:50:19 -1000 Subject: [PATCH 1/8] Bug 1556813 Part 1 - Allow lazy script parsing when recording/replaying, r=jandem. --HG-- extra : rebase_source : d32706d8c3401754804bb6e1bbbeef856fa61c09 --- js/src/jsapi.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/js/src/jsapi.cpp b/js/src/jsapi.cpp index c03f455e3f90..4aa35b2b92cb 100644 --- a/js/src/jsapi.cpp +++ b/js/src/jsapi.cpp @@ -3580,12 +3580,9 @@ JS::CompileOptions::CompileOptions(JSContext* cx) bigIntEnabledOption = cx->realm()->creationOptions().getBigIntEnabled(); fieldsEnabledOption = cx->realm()->creationOptions().getFieldsEnabled(); - // Certain modes of operation disallow syntax parsing in general. The replay - // debugger requires scripts to be constructed in a consistent order, which - // might not happen with lazy parsing. + // Certain modes of operation disallow syntax parsing in general. forceFullParse_ = cx->realm()->behaviors().disableLazyParsing() || - coverage::IsLCovEnabled() || - mozilla::recordreplay::IsRecordingOrReplaying(); + coverage::IsLCovEnabled(); // If instrumentation is enabled in the realm, the compiler should insert the // requested kinds of instrumentation into all scripts. From 6bc30082c31291a1dafb0ffbedbe3f662f2eccf4 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Tue, 4 Jun 2019 10:55:16 -1000 Subject: [PATCH 2/8] Bug 1556813 Part 4 - Add Debugger API to create native functions in a debuggee's realm, r=jorendorff. --HG-- extra : rebase_source : f57818bd089d16ac68ff8a523f752f9465bd3a16 --- js/src/debugger/Object.cpp | 66 +++++++++++++++++++ js/src/debugger/Object.h | 6 ++ js/src/doc/Debugger/Debugger.Object.md | 27 ++------ .../Object-makeDebuggeeNativeFunction-01.js | 28 ++++++++ 4 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 js/src/jit-test/tests/debug/Object-makeDebuggeeNativeFunction-01.js diff --git a/js/src/debugger/Object.cpp b/js/src/debugger/Object.cpp index 9117a38f3a9a..464d5ae76ba9 100644 --- a/js/src/debugger/Object.cpp +++ b/js/src/debugger/Object.cpp @@ -1213,6 +1213,20 @@ bool DebuggerObject::makeDebuggeeValueMethod(JSContext* cx, unsigned argc, return DebuggerObject::makeDebuggeeValue(cx, object, args[0], args.rval()); } +/* static */ +bool DebuggerObject::makeDebuggeeNativeFunctionMethod(JSContext* cx, + unsigned argc, + Value* vp) { + THIS_DEBUGOBJECT(cx, argc, vp, "makeDebuggeeNativeFunction", args, object); + if (!args.requireAtLeast( + cx, "Debugger.Object.prototype.makeDebuggeeNativeFunction", 1)) { + return false; + } + + return DebuggerObject::makeDebuggeeNativeFunction(cx, object, args[0], + args.rval()); +} + /* static */ bool DebuggerObject::unsafeDereferenceMethod(JSContext* cx, unsigned argc, Value* vp) { @@ -1405,6 +1419,8 @@ const JSFunctionSpec DebuggerObject::methods_[] = { JS_FN("executeInGlobalWithBindings", DebuggerObject::executeInGlobalWithBindingsMethod, 2, 0), JS_FN("makeDebuggeeValue", DebuggerObject::makeDebuggeeValueMethod, 1, 0), + JS_FN("makeDebuggeeNativeFunction", + DebuggerObject::makeDebuggeeNativeFunctionMethod, 1, 0), JS_FN("unsafeDereference", DebuggerObject::unsafeDereferenceMethod, 0, 0), JS_FN("unwrap", DebuggerObject::unwrapMethod, 0, 0), JS_FN("setInstrumentation", DebuggerObject::setInstrumentationMethod, 2, 0), @@ -2303,6 +2319,56 @@ bool DebuggerObject::makeDebuggeeValue(JSContext* cx, return true; } +/* static */ +bool DebuggerObject::makeDebuggeeNativeFunction(JSContext* cx, + HandleDebuggerObject object, + HandleValue value, + MutableHandleValue result) { + RootedObject referent(cx, object->referent()); + Debugger* dbg = object->owner(); + + if (!value.isObject()) { + JS_ReportErrorASCII(cx, "Need object"); + return false; + } + + RootedObject obj(cx, &value.toObject()); + if (!obj->is()) { + JS_ReportErrorASCII(cx, "Need function"); + return false; + } + + RootedFunction fun(cx, &obj->as()); + if (!fun->isNative() || fun->isExtended()) { + JS_ReportErrorASCII(cx, "Need native function"); + return false; + } + + RootedValue newValue(cx); + { + Maybe ar; + EnterDebuggeeObjectRealm(cx, ar, referent); + + unsigned nargs = fun->nargs(); + RootedAtom name(cx, fun->displayAtom()); + JSFunction* newFun = NewNativeFunction(cx, fun->native(), nargs, name); + if (!newFun) { + return false; + } + + newValue.setObject(*newFun); + } + + // Back in the debugger's compartment, produce a new Debugger.Object + // instance referring to the wrapped argument. + if (!dbg->wrapDebuggeeValue(cx, &newValue)) { + return false; + } + + result.set(newValue); + return true; +} + /* static */ bool DebuggerObject::unsafeDereference(JSContext* cx, HandleDebuggerObject object, diff --git a/js/src/debugger/Object.h b/js/src/debugger/Object.h index 3b725fb1ad2d..82d9137728a2 100644 --- a/js/src/debugger/Object.h +++ b/js/src/debugger/Object.h @@ -140,6 +140,9 @@ class DebuggerObject : public NativeObject { HandleDebuggerObject object, HandleValue value, MutableHandleValue result); + static MOZ_MUST_USE bool makeDebuggeeNativeFunction( + JSContext* cx, HandleDebuggerObject object, HandleValue value, + MutableHandleValue result); static MOZ_MUST_USE bool unsafeDereference(JSContext* cx, HandleDebuggerObject object, MutableHandleObject result); @@ -302,6 +305,9 @@ class DebuggerObject : public NativeObject { Value* vp); static MOZ_MUST_USE bool makeDebuggeeValueMethod(JSContext* cx, unsigned argc, Value* vp); + static MOZ_MUST_USE bool makeDebuggeeNativeFunctionMethod(JSContext* cx, + unsigned argc, + Value* vp); static MOZ_MUST_USE bool unsafeDereferenceMethod(JSContext* cx, unsigned argc, Value* vp); static MOZ_MUST_USE bool unwrapMethod(JSContext* cx, unsigned argc, diff --git a/js/src/doc/Debugger/Debugger.Object.md b/js/src/doc/Debugger/Debugger.Object.md index ffa52025f1e5..00788f909768 100644 --- a/js/src/doc/Debugger/Debugger.Object.md +++ b/js/src/doc/Debugger/Debugger.Object.md @@ -435,26 +435,6 @@ of exotic object like an opaque wrapper. `Object.isExtensible` function, except that the object inspected is implicit and in a different compartment from the caller.) -copy(value) -: Apply the HTML5 "structured cloning" algorithm to create a copy of - value in the referent's global object (and thus in the referent's - compartment), and return a `Debugger.Object` instance referring to the - copy. - - Note that this returns primitive values unchanged. This means you can - use `Debugger.Object.prototype.copy` as a generic "debugger value to - debuggee value" conversion function—within the limitations of the - "structured cloning" algorithm. - -create(prototype, [properties]) -: Create a new object in the referent's global (and thus in the - referent's compartment), and return a `Debugger.Object` referring to - it. The new object's prototype is prototype, which must be an - `Debugger.Object` instance. The new object's properties are as given by - properties, as if properties were passed to - `Debugger.Object.prototype.defineProperties`, with the new - `Debugger.Object` instance as the `this` value. - makeDebuggeeValue(value) : Return the debuggee value that represents value in the debuggee. If value is a primitive, we return it unchanged; if value @@ -473,6 +453,13 @@ of exotic object like an opaque wrapper. `Debugger.Object` instance that presents o as it would be seen by code in d's compartment. +makeDebuggeeNativeFunction(value) +: If value is a native function in the debugger's compartment, create + an equivalent function for the same native in the debuggee's realm, and + return a `Debugger.Object` instance for the new function. The new function + can be accessed by code in the debuggee without going through a cross + compartment wrapper. + decompile([pretty]) : If the referent is a function that is debuggee code, return the JavaScript source code for a function definition equivalent to the diff --git a/js/src/jit-test/tests/debug/Object-makeDebuggeeNativeFunction-01.js b/js/src/jit-test/tests/debug/Object-makeDebuggeeNativeFunction-01.js new file mode 100644 index 000000000000..996ad7cffcb6 --- /dev/null +++ b/js/src/jit-test/tests/debug/Object-makeDebuggeeNativeFunction-01.js @@ -0,0 +1,28 @@ +// Debugger.Object.prototype.makeDebuggeeNativeFunction does what it is +// supposed to do. + +load(libdir + "asserts.js"); + +var g = newGlobal({newCompartment: true}); +var dbg = new Debugger(); +var gw = dbg.addDebuggee(g); + +// It would be nice if we could test that this call doesn't produce a CCW, +// and that calling makeDebuggeeValue instead does, but +// Debugger.Object.isProxy only returns true for scripted proxies. +const push = gw.makeDebuggeeNativeFunction(Array.prototype.push); + +gw.setProperty("push", push); +g.eval("var x = []; push.call(x, 2); x.push(3)"); +assertEq(g.x[0], 2); +assertEq(g.x[1], 3); + +// Interpreted functions should throw. +assertThrowsInstanceOf(() => { + gw.makeDebuggeeNativeFunction(() => {}); +}, Error); + +// Native functions which have extended slots should throw. +let f; +new Promise(resolve => { f = resolve; }) +assertThrowsInstanceOf(() => gw.makeDebuggeeNativeFunction(f), Error); From 35c0ee1da775606472d59323b90803d789d11add Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:42:49 -1000 Subject: [PATCH 3/8] Bug 1556813 Part 5 - Server changes for instrumentation based control logic, r=loganfsmyth. --HG-- extra : rebase_source : b678f9170a1d2250c5916d9a9dcbfe7ed82c0a0b extra : histedit_source : 548ebf0a3570200dcf856cff249a518a568e9d5a --- devtools/server/actors/breakpoint.js | 37 +++++++++++++++++----------- devtools/server/actors/thread.js | 36 +++++++++++++-------------- devtools/shared/specs/thread.js | 2 ++ 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/devtools/server/actors/breakpoint.js b/devtools/server/actors/breakpoint.js index 16786f7b91bb..4b0b7d76727b 100644 --- a/devtools/server/actors/breakpoint.js +++ b/devtools/server/actors/breakpoint.js @@ -46,7 +46,7 @@ function BreakpointActor(threadActor, location) { BreakpointActor.prototype = { setOptions(options) { for (const [script, offsets] of this.scripts) { - this._updateOptionsForScript(script, offsets, options); + this._newOffsetsOrOptions(script, offsets, this.options, options); } this.options = options; @@ -71,11 +71,7 @@ BreakpointActor.prototype = { */ addScript: function(script, offsets) { this.scripts.set(script, offsets.concat(this.scripts.get(offsets) || [])); - for (const offset of offsets) { - script.setBreakpoint(offset, this); - } - - this._updateOptionsForScript(script, offsets, this.options); + this._newOffsetsOrOptions(script, offsets, null, this.options); }, /** @@ -88,12 +84,20 @@ BreakpointActor.prototype = { this.scripts.clear(); }, - // Update any state affected by changing options on a script this breakpoint - // is associated with. - _updateOptionsForScript(script, offsets, options) { + /** + * Called on changes to this breakpoint's script offsets or options. + */ + _newOffsetsOrOptions(script, offsets, oldOptions, options) { // When replaying, logging breakpoints are handled using an API to get logged // messages from throughout the recording. if (this.threadActor.dbg.replaying && options.logValue) { + if ( + oldOptions && + oldOptions.logValue == options.logValue && + oldOptions.condition == options.condition + ) { + return; + } for (const offset of offsets) { const { lineNumber, columnNumber } = script.getOffsetLocation(offset); script.replayVirtualConsoleLog( @@ -113,6 +117,15 @@ BreakpointActor.prototype = { } ); } + return; + } + + // In all other cases, this is used as a script breakpoint handler. + // Clear any existing handler first in case this is called multiple times + // after options change. + for (const offset of offsets) { + script.clearBreakpoint(this, offset); + script.setBreakpoint(offset, this); } }, @@ -202,12 +215,6 @@ BreakpointActor.prototype = { const reason = { type: "breakpoint", actors: [this.actorID] }; const { condition, logValue } = this.options || {}; - // When replaying, breakpoints with log values are handled via - // _updateOptionsForScript. - if (logValue && this.threadActor.dbg.replaying) { - return undefined; - } - if (condition) { const { result, message } = this.checkCondition(frame, condition); diff --git a/devtools/server/actors/thread.js b/devtools/server/actors/thread.js index 70c1ca40e4a9..9c683edce88b 100644 --- a/devtools/server/actors/thread.js +++ b/devtools/server/actors/thread.js @@ -163,20 +163,7 @@ const ThreadActor = ActorClassWithSpec(threadSpec, { 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 = this.replayingOnPositionChange.bind( - this, - sendProgress - ); + this._dbg.replayingOnPositionChange = this._makeReplayingOnPositionChange(); } // Keep the debugger disabled until a client attaches. this._dbg.enabled = this._state != "detached"; @@ -1849,10 +1836,23 @@ const ThreadActor = ActorClassWithSpec(threadSpec, { * changed its position: a checkpoint was reached or a switch between a * recording and replaying child process occurred. */ - replayingOnPositionChange: function(sendProgress) { - const recording = this.dbg.replayIsRecording(); - const executionPoint = this.dbg.replayCurrentExecutionPoint(); - sendProgress(recording, executionPoint); + _makeReplayingOnPositionChange() { + return throttle(() => { + if (this.attached) { + const recording = this.dbg.replayIsRecording(); + const executionPoint = this.dbg.replayCurrentExecutionPoint(); + const unscannedRegions = this.dbg.replayUnscannedRegions(); + const cachedPoints = this.dbg.replayCachedPoints(); + this.conn.send({ + type: "progress", + from: this.actorID, + recording, + executionPoint, + unscannedRegions, + cachedPoints, + }); + } + }, 100); }, /** diff --git a/devtools/shared/specs/thread.js b/devtools/shared/specs/thread.js index 610e8b6ee5a6..15fdbce74ffe 100644 --- a/devtools/shared/specs/thread.js +++ b/devtools/shared/specs/thread.js @@ -42,6 +42,8 @@ const threadSpec = generateActorSpec({ progress: { recording: Option(0, "json"), executionPoint: Option(0, "json"), + unscannedRegions: Option(0, "json"), + cachedPoints: Option(0, "json"), }, }, From d4d55cdb952273c31a6acccf660203790a35211c Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:43:35 -1000 Subject: [PATCH 4/8] Bug 1556813 Part 6 - Watch for provisional logpoint messages in webconsole, r=nchevobbe. --HG-- extra : rebase_source : 7ba0c4a127644d3523fd84f6f6eea5f5d70eab27 extra : histedit_source : f1eb8349ba3f2a72134e8502509102fc5c5a619d --- .../client/webconsole/reducers/messages.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/devtools/client/webconsole/reducers/messages.js b/devtools/client/webconsole/reducers/messages.js index 6795b4e35148..8ab1a4267b1e 100644 --- a/devtools/client/webconsole/reducers/messages.js +++ b/devtools/client/webconsole/reducers/messages.js @@ -60,6 +60,11 @@ ChromeUtils.defineModuleGetter( "pointPrecedes", "resource://devtools/shared/execution-point-utils.js" ); +ChromeUtils.defineModuleGetter( + this, + "pointEquals", + "resource://devtools/shared/execution-point-utils.js" +); const { UPDATE_REQUEST } = require("devtools/client/netmonitor/src/constants"); @@ -189,6 +194,22 @@ function addMessage(newMessage, state, filtersState, prefsState, uiState) { state.hasExecutionPoints = true; } + // When replaying, we might get two messages with the same execution point and + // logpoint ID. In this case the first message is provisional and should be + // removed. + const removedIds = []; + if (newMessage.logpointId) { + const existingMessage = [...state.messagesById.values()].find(existing => { + return ( + existing.logpointId == newMessage.logpointId && + pointEquals(existing.executionPoint, newMessage.executionPoint) + ); + }); + if (existingMessage) { + removedIds.push(existingMessage.id); + } + } + // Check if the current message could be placed in a Warning Group. // This needs to be done before setting the new message in messagesById so we have a // proper message. @@ -331,7 +352,7 @@ function addMessage(newMessage, state, filtersState, prefsState, uiState) { state.networkMessagesUpdateById[newMessage.actor] = newMessage; } - return state; + return removeMessagesFromState(state, removedIds); } /* eslint-enable complexity */ From d62dcc9f5fd4760b17d8b2613834046735cd694f Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:45:04 -1000 Subject: [PATCH 5/8] Bug 1556813 Part 7 - Instrumentation based control logic, r=loganfsmyth. --HG-- extra : rebase_source : 8d2dbd2017551651f88994de2937e0eb8832ae55 extra : histedit_source : df5a94b656f2a866315135914058ad3540c3d5be --- devtools/server/actors/replay/control.js | 1070 ++++++++++++----- devtools/server/actors/replay/debugger.js | 47 +- devtools/server/actors/replay/graphics.js | 34 +- devtools/server/actors/replay/replay.js | 408 +++++-- devtools/server/actors/replay/rrIGraphics.idl | 4 +- devtools/server/actors/replay/rrIReplay.idl | 1 + devtools/shared/execution-point-utils.js | 78 +- 7 files changed, 1154 insertions(+), 488 deletions(-) diff --git a/devtools/server/actors/replay/control.js b/devtools/server/actors/replay/control.js index dc9912232b51..f59baeb85d0b 100644 --- a/devtools/server/actors/replay/control.js +++ b/devtools/server/actors/replay/control.js @@ -23,16 +23,20 @@ Cu.evalInSandbox( "Components.utils.import('resource://gre/modules/jsdebugger.jsm');" + "Components.utils.import('resource://gre/modules/Services.jsm');" + "Components.utils.import('resource://devtools/shared/execution-point-utils.js');" + + "Components.utils.import('resource://gre/modules/Timer.jsm');" + "addDebuggerToGlobal(this);", sandbox ); const { RecordReplayControl, Services, - pointPrecedes, pointEquals, + pointToString, + findClosestPoint, + pointArrayIncludes, positionEquals, positionSubsumes, + setInterval, } = sandbox; const InvalidCheckpointId = 0; @@ -114,15 +118,12 @@ function ChildProcess(id, recording) { // Last reported memory usage for this child. this.lastMemoryUsage = null; - // Manifests which this child needs to send asynchronously. - this.asyncManifests = []; - // All checkpoints which this process has saved or will save, which is a // subset of all the saved checkpoints. this.savedCheckpoints = new Set(recording ? [] : [FirstCheckpointId]); - // All saved checkpoints whose region of the recording has been scanned by - // this child. + // All saved checkpoints whose region of the recording has been scanned or is + // in the process of being scanned by this child. this.scannedCheckpoints = new Set(); // Checkpoints in savedCheckpoints which haven't been sent to the child yet. @@ -143,6 +144,12 @@ function ChildProcess(id, recording) { } }, }; + + // The time the manifest was sent to the child. + this.manifestSendTime = Date.now(); + + // Per-child state about asynchronous manifests. + this.asyncManifestInfo = new AsyncManifestChildInfo(); } ChildProcess.prototype = { @@ -166,10 +173,13 @@ ChildProcess.prototype = { // contents: The JSON object to send to the child describing the operation. // onFinished: A callback which is called after the manifest finishes with the // manifest's result. + // destination: An optional destination where the child will end up. + // expectedDuration: Optional estimate of the time needed to run the manifest. sendManifest(manifest) { assert(this.paused); this.paused = false; this.manifest = manifest; + this.manifestSendTime = Date.now(); dumpv(`SendManifest #${this.id} ${stringify(manifest.contents)}`); RecordReplayControl.sendManifest(this.id, manifest.contents); @@ -219,82 +229,41 @@ ChildProcess.prototype = { return rv; }, - // Send a manifest to this child asynchronously. The child does not need to be - // paused, and will process async manifests in the order they were added. - // Async manifests can end up being reassigned to a different child. This - // returns a promise that resolves when the manifest finishes. Async manifests - // have the following properties: - // - // shouldSkip: Optional callback invoked with the executing child when it is - // about to be sent. Returns true if the manifest should not be sent, and - // the promise resolved immediately. - // - // contents: Callback invoked with the executing child when it is being sent. - // Returns the contents to send to the child. - // - // onFinished: Optional callback invoked with the executing child and manifest - // response after the manifest finishes. - // - // noReassign: Optional boolean which can be set to prevent the manifest from - // being reassigned to another child. - // - // The optional point parameter specifies an execution point which the child - // should be paused at before executing the manifest. Otherwise it could be - // paused anywhere. The returned value is the child which ended up executing - // the manifest. - sendManifestAsync(manifest, point) { - pokeChildSoon(this); - return new Promise(resolve => { - this.asyncManifests.push({ resolve, manifest, point }); - }); + // Get the last saved checkpoint equal to or prior to checkpoint. + lastSavedCheckpoint(checkpoint) { + while (!this.savedCheckpoints.has(checkpoint)) { + checkpoint--; + } + return checkpoint; }, - // Return true if progress was made while executing the next async manifest. - processAsyncManifest() { - if (this.asyncManifests.length == 0) { - return false; - } - const { resolve, manifest, point } = this.asyncManifests[0]; - if (manifest.shouldSkip && manifest.shouldSkip(this)) { - resolve(this); - this.asyncManifests.shift(); - pokeChildSoon(this); - return true; - } - - // If this is the active child then we can't process arbitrary manifests. - // Only handle those which cannot be reassigned, and hand off others to - // random other children. - if (this == gActiveChild && !manifest.noReassign) { - const child = pickReplayingChild(); - child.asyncManifests.push(this.asyncManifests.shift()); - pokeChildSoon(child); - pokeChildSoon(this); - return true; - } - - if (point && maybeReachPoint(this, point)) { - return true; - } - this.sendManifest({ - contents: manifest.contents(this), - onFinished: data => { - if (manifest.onFinished) { - manifest.onFinished(this, data); + // Get an estimate of the amount of time required for this child to reach an + // execution point. + timeToReachPoint(point) { + let startDelay = 0, + startPoint = this.lastPausePoint; + if (!this.paused) { + if (this.manifest.expectedDuration) { + const elapsed = Date.now() - this.manifestSendTime; + if (elapsed < this.manifest.expectedDuration) { + startDelay = this.manifest.expectedDuration - elapsed; } - resolve(this); - pokeChildSoon(this); - }, - }); - this.asyncManifests.shift(); - - // If this is the active child then we shouldn't leave it in an unpaused - // state, so callers can interact with it as expected. - if (this == gActiveChild) { - this.waitUntilPaused(); + } + if (this.manifest.destination) { + startPoint = this.manifest.destination; + } } - - return true; + let startCheckpoint = startPoint.checkpoint; + // Assume rewinding is necessary if the child is in between checkpoints. + if (startPoint.position) { + startCheckpoint = this.lastSavedCheckpoint(startCheckpoint); + } + if (point.checkpoint < startCheckpoint) { + startCheckpoint = this.lastSavedCheckpoint(point.checkpoint); + } + return ( + startDelay + checkpointRangeDuration(startCheckpoint, point.checkpoint) + ); }, }; @@ -316,24 +285,164 @@ function lookupChild(id) { return gReplayingChildren[id]; } -// ID of the last replaying child we picked for an operation. -let lastPickedChildId = 0; - -function pickReplayingChild() { - // Use a round robin approach when picking new children for operations, - // to try to keep activity among the children evenly distributed. - while (true) { - lastPickedChildId = (lastPickedChildId + 1) % gReplayingChildren.length; - const child = gReplayingChildren[lastPickedChildId]; +function closestChild(point) { + let minChild = null, + minTime = Infinity; + for (const child of gReplayingChildren) { if (child) { - return child; + const time = child.timeToReachPoint(point); + if (time < minTime) { + minChild = child; + minTime = time; + } } } + return minChild; } // The singleton ReplayDebugger, or undefined if it doesn't exist. let gDebugger; +//////////////////////////////////////////////////////////////////////////////// +// Asynchronous Manifests +//////////////////////////////////////////////////////////////////////////////// + +// Asynchronous manifest worklists. +const gAsyncManifests = new Set(); +const gAsyncManifestsLowPriority = new Set(); + +function asyncManifestWorklist(lowPriority) { + return lowPriority ? gAsyncManifestsLowPriority : gAsyncManifests; +} + +// Send a manifest asynchronously, returning a promise that resolves when the +// manifest has been finished. Async manifests have the following properties: +// +// shouldSkip: Callback invoked before sending the manifest. Returns true if the +// manifest should not be sent, and the promise resolved immediately. +// +// contents: Callback invoked with the executing child when it is being sent. +// Returns the contents to send to the child. +// +// onFinished: Callback invoked with the executing child and manifest response +// after the manifest finishes. +// +// point: Optional point which the associated child must reach before sending +// the manifest. +// +// scanCheckpoint: If the manifest relies on scan data, the saved checkpoint +// whose range the child must have scanned. Such manifests do not have side +// effects in the child, and can be sent to the active child. +// +// lowPriority: True if this manifest should be processed only after all other +// manifests have been processed. +// +// destination: An optional destination where the child will end up. +// +// expectedDuration: Optional estimate of the time needed to run the manifest. +function sendAsyncManifest(manifest) { + pokeChildrenSoon(); + return new Promise(resolve => { + manifest.resolve = resolve; + asyncManifestWorklist(manifest.lowPriority).add(manifest); + }); +} + +function AsyncManifestChildInfo() { + // Any async manifest this child has partially processed. + this.inProgressManifest = null; +} + +// Pick the best async manifest for a child to process. +function pickAsyncManifest(child, lowPriority) { + const worklist = asyncManifestWorklist(lowPriority); + + let best = null, + bestTime = Infinity; + for (const manifest of worklist) { + // Prune any manifests that can be skipped. + if (manifest.shouldSkip()) { + manifest.resolve(); + worklist.delete(manifest); + continue; + } + + // Manifests relying on scan data can be handled by any child, at any point. + // These are the best ones to pick. + if (manifest.scanCheckpoint) { + if (child.scannedCheckpoints.has(manifest.scanCheckpoint)) { + assert(!manifest.point); + best = manifest; + break; + } else { + continue; + } + } + + // The active child cannot process other asynchronous manifests which don't + // rely on scan data, as they can move the child or have other side effects. + if (child == gActiveChild) { + continue; + } + + // Pick the manifest which requires the least amount of travel time. + assert(manifest.point); + const time = child.timeToReachPoint(manifest.point); + if (time < bestTime) { + best = manifest; + bestTime = time; + } + } + + if (best) { + worklist.delete(best); + } + + return best; +} + +function processAsyncManifest(child) { + // If the child has partially processed a manifest, continue with it. + let manifest = child.asyncManifestInfo.inProgressManifest; + child.asyncManifestInfo.inProgressManifest = null; + + if (manifest && child == gActiveChild) { + // After a child becomes the active child, it gives up on any in progress + // async manifest it was processing. + sendAsyncManifest(manifest); + manifest = null; + } + + if (!manifest) { + manifest = pickAsyncManifest(child, /* lowPriority */ false); + if (!manifest) { + manifest = pickAsyncManifest(child, /* lowPriority */ true); + if (!manifest) { + return false; + } + } + } + + if (manifest.point && maybeReachPoint(child, manifest.point)) { + // The manifest has been partially processed. + child.asyncManifestInfo.inProgressManifest = manifest; + return true; + } + + child.sendManifest({ + contents: manifest.contents(child), + onFinished: data => { + manifest.onFinished(child, data); + manifest.resolve(); + pokeChildSoon(child); + }, + destination: manifest.destination, + expectedDuration: manifest.expectedDuration, + }); + + return true; +} + //////////////////////////////////////////////////////////////////////////////// // Application State //////////////////////////////////////////////////////////////////////////////// @@ -352,11 +461,10 @@ function CheckpointInfo() { // Execution point at the checkpoint. this.point = null; - // If the checkpoint is saved, the replaying child responsible for saving it - // and scanning the region up to the next saved checkpoint. - this.owner = null; + // Whether the checkpoint is saved. + this.saved = false; - // If the checkpoint is saved, the time it was assigned an owner. + // If the checkpoint is saved, the time it was assigned to a child. this.assignTime = null; // If the checkpoint is saved and scanned, the time it finished being scanned. @@ -373,23 +481,23 @@ function getCheckpointInfo(id) { return gCheckpoints[id]; } -// How much execution time has elapsed since a checkpoint. -function timeSinceCheckpoint(id) { +// How much execution time elapses between two checkpoints. +function checkpointRangeDuration(start, end) { let time = 0; - for (let i = id ? id : FirstCheckpointId; i < gCheckpoints.length; i++) { + for (let i = start; i < end; i++) { time += gCheckpoints[i].duration; } return time; } +// How much execution time has elapsed since a checkpoint. +function timeSinceCheckpoint(id) { + return checkpointRangeDuration(id, gCheckpoints.length); +} + // How much execution time is captured by a saved checkpoint. function timeForSavedCheckpoint(id) { - const next = nextSavedCheckpoint(id); - let time = 0; - for (let i = id; i < next; i++) { - time += gCheckpoints[i].duration; - } - return time; + return checkpointRangeDuration(id, nextSavedCheckpoint(id)); } // The checkpoint up to which the recording runs. @@ -398,15 +506,23 @@ let gLastFlushCheckpoint = InvalidCheckpointId; // How often we want to flush the recording. const FlushMs = 0.5 * 1000; +// ID of the last replaying child we picked for saving a checkpoint. +let gLastPickedChildId = 0; + function addSavedCheckpoint(checkpoint) { - if (getCheckpointInfo(checkpoint).owner) { - return; + // Use a round robin approach when picking children for saving checkpoints. + let child; + while (true) { + gLastPickedChildId = (gLastPickedChildId + 1) % gReplayingChildren.length; + child = gReplayingChildren[gLastPickedChildId]; + if (child) { + break; + } } - const owner = pickReplayingChild(); - getCheckpointInfo(checkpoint).owner = owner; + getCheckpointInfo(checkpoint).saved = true; getCheckpointInfo(checkpoint).assignTime = Date.now(); - owner.addSavedCheckpoint(checkpoint); + child.addSavedCheckpoint(checkpoint); } function addCheckpoint(checkpoint, duration) { @@ -414,19 +530,10 @@ function addCheckpoint(checkpoint, duration) { getCheckpointInfo(checkpoint).duration = duration; } -function ownerChild(checkpoint) { - while (!getCheckpointInfo(checkpoint).owner) { - checkpoint--; - } - return getCheckpointInfo(checkpoint).owner; -} - // Unpause a child and restore it to its most recent saved checkpoint at or // before target. function restoreCheckpoint(child, target) { - while (!child.savedCheckpoints.has(target)) { - target--; - } + assert(child.savedCheckpoints.has(target)); child.sendManifest({ contents: { kind: "restoreCheckpoint", target }, onFinished({ restoredCheckpoint }) { @@ -434,6 +541,7 @@ function restoreCheckpoint(child, target) { child.divergedFromRecording = false; pokeChildSoon(child); }, + destination: checkpointExecutionPoint(target), }); } @@ -448,11 +556,14 @@ function maybeReachPoint(child, endpoint) { return false; } if (child.divergedFromRecording || child.pausePoint().position) { - restoreCheckpoint(child, child.pausePoint().checkpoint); + restoreCheckpoint( + child, + child.lastSavedCheckpoint(child.pausePoint().checkpoint) + ); return true; } if (endpoint.checkpoint < child.pauseCheckpoint()) { - restoreCheckpoint(child, endpoint.checkpoint); + restoreCheckpoint(child, child.lastSavedCheckpoint(endpoint.checkpoint)); return true; } child.sendManifest({ @@ -464,19 +575,27 @@ function maybeReachPoint(child, endpoint) { onFinished() { pokeChildSoon(child); }, + destination: endpoint, + expectedDuration: checkpointRangeDuration( + child.pausePoint().checkpoint, + endpoint.checkpoint + ), }); return true; } function nextSavedCheckpoint(checkpoint) { - assert(gCheckpoints[checkpoint].owner); + assert(gCheckpoints[checkpoint].saved); // eslint-disable-next-line no-empty - while (!gCheckpoints[++checkpoint].owner) {} + while (!gCheckpoints[++checkpoint].saved) {} return checkpoint; } function forSavedCheckpointsInRange(start, end, callback) { - assert(gCheckpoints[start].owner); + if (start == FirstCheckpointId && !gCheckpoints[start].saved) { + return; + } + assert(gCheckpoints[start].saved); for ( let checkpoint = start; checkpoint < end; @@ -487,7 +606,7 @@ function forSavedCheckpointsInRange(start, end, callback) { } function getSavedCheckpoint(checkpoint) { - while (!gCheckpoints[checkpoint].owner) { + while (!gCheckpoints[checkpoint].saved) { checkpoint--; } return checkpoint; @@ -506,7 +625,7 @@ function pokeChild(child) { return; } - if (child.processAsyncManifest()) { + if (processAsyncManifest(child)) { return; } @@ -523,7 +642,10 @@ function pokeChildSoon(child) { Services.tm.dispatchToMainThread(() => pokeChild(child)); } +let gPendingPokeChildren = false; + function pokeChildren() { + gPendingPokeChildren = false; for (const child of gReplayingChildren) { if (child) { pokeChild(child); @@ -532,7 +654,10 @@ function pokeChildren() { } function pokeChildrenSoon() { - Services.tm.dispatchToMainThread(() => pokeChildren()); + if (!gPendingPokeChildren) { + Services.tm.dispatchToMainThread(() => pokeChildren()); + gPendingPokeChildren = true; + } } //////////////////////////////////////////////////////////////////////////////// @@ -548,40 +673,83 @@ const gBreakpoints = []; // allows the execution points for each script breakpoint position to be queried // by sending a manifest to the child which performed the scan. -// Ensure the region for a saved checkpoint has been scanned by some child, -// returning a promise that resolves with that child. -function scanRecording(checkpoint) { - assert(checkpoint < gLastFlushCheckpoint); - +function findScanChild(checkpoint) { for (const child of gReplayingChildren) { if (child && child.scannedCheckpoints.has(checkpoint)) { return child; } } + return null; +} + +// Ensure the region for a saved checkpoint has been scanned by some child. +async function scanRecording(checkpoint) { + assert(checkpoint < gLastFlushCheckpoint); + + const child = findScanChild(checkpoint); + if (child) { + return; + } - const initialChild = ownerChild(checkpoint); const endpoint = nextSavedCheckpoint(checkpoint); - return initialChild.sendManifestAsync( - { - shouldSkip: child => child.scannedCheckpoints.has(checkpoint), - contents(child) { - return { - kind: "scanRecording", - endpoint, - needSaveCheckpoints: child.flushNeedSaveCheckpoints(), - }; - }, - onFinished(child, { duration }) { - child.scannedCheckpoints.add(checkpoint); - const info = getCheckpointInfo(checkpoint); - if (!info.scanTime) { - info.scanTime = Date.now(); - info.scanDuration = duration; - } - }, + await sendAsyncManifest({ + shouldSkip: () => !!findScanChild(checkpoint), + contents(child) { + child.scannedCheckpoints.add(checkpoint); + return { + kind: "scanRecording", + endpoint, + needSaveCheckpoints: child.flushNeedSaveCheckpoints(), + }; }, - checkpointExecutionPoint(checkpoint) + onFinished(child, { duration }) { + const info = getCheckpointInfo(checkpoint); + if (!info.scanTime) { + info.scanTime = Date.now(); + info.scanDuration = duration; + } + if (gDebugger) { + gDebugger._callOnPositionChange(); + } + }, + point: checkpointExecutionPoint(checkpoint), + destination: checkpointExecutionPoint(endpoint), + expectedDuration: checkpointRangeDuration(checkpoint, endpoint) * 5, + }); + + assert(findScanChild(checkpoint)); +} + +function unscannedRegions() { + const result = []; + + function addRegion(startCheckpoint, endCheckpoint) { + const start = checkpointExecutionPoint(startCheckpoint).progress; + const end = checkpointExecutionPoint(endCheckpoint).progress; + + if (result.length && result[result.length - 1].end == start) { + result[result.length - 1].end = end; + } else { + result.push({ start, end }); + } + } + + forSavedCheckpointsInRange( + FirstCheckpointId, + gLastFlushCheckpoint, + checkpoint => { + if (!findScanChild(checkpoint)) { + addRegion(checkpoint, nextSavedCheckpoint(checkpoint)); + } + } ); + + const lastFlush = gLastFlushCheckpoint || FirstCheckpointId; + if (lastFlush != gRecordingEndpoint) { + addRegion(lastFlush, gMainChild.lastPausePoint.checkpoint); + } + + return result; } // Map from saved checkpoints to information about breakpoint hits within the @@ -594,31 +762,38 @@ function canFindHits(position) { return position.kind == "Break" || position.kind == "OnStep"; } +function findExistingHits(checkpoint, position) { + const checkpointHits = gHitSearches.get(checkpoint); + if (!checkpointHits) { + return null; + } + const entry = checkpointHits.find(({ position: existingPosition, hits }) => { + return positionEquals(position, existingPosition); + }); + return entry ? entry.hits : null; +} + // Find all hits on the specified position between a saved checkpoint and the // following saved checkpoint, using data from scanning the recording. This // returns a promise that resolves with the resulting hits. async function findHits(checkpoint, position) { assert(canFindHits(position)); - assert(gCheckpoints[checkpoint].owner); + assert(gCheckpoints[checkpoint].saved); if (!gHitSearches.has(checkpoint)) { gHitSearches.set(checkpoint, []); } // Check if we already have the hits. - if (!gHitSearches.has(checkpoint)) { - gHitSearches.set(checkpoint, []); - } - const checkpointHits = gHitSearches.get(checkpoint); - let hits = findExistingHits(); + let hits = findExistingHits(checkpoint, position); if (hits) { return hits; } - const child = await scanRecording(checkpoint); + await scanRecording(checkpoint); const endpoint = nextSavedCheckpoint(checkpoint); - await child.sendManifestAsync({ - shouldSkip: () => findExistingHits() != null, + await sendAsyncManifest({ + shouldSkip: () => !!findExistingHits(checkpoint, position), contents() { return { kind: "findHits", @@ -627,24 +802,28 @@ async function findHits(checkpoint, position) { endpoint, }; }, - onFinished: (_, hits) => checkpointHits.push({ position, hits }), - // findHits has to be sent to the child which scanned this portion of the - // recording. It can be sent to the active child, though, because it - // does not have side effects. - noReassign: true, + onFinished(_, hits) { + if (!gHitSearches.has(checkpoint)) { + gHitSearches.set(checkpoint, []); + } + const checkpointHits = gHitSearches.get(checkpoint); + checkpointHits.push({ position, hits }); + }, + scanCheckpoint: checkpoint, }); - hits = findExistingHits(); + hits = findExistingHits(checkpoint, position); assert(hits); return hits; +} - function findExistingHits() { - const entry = checkpointHits.find( - ({ position: existingPosition, hits }) => { - return positionEquals(position, existingPosition); - } - ); - return entry ? entry.hits : null; +// Asynchronously find all hits on a breakpoint's position. +async function findBreakpointHits(checkpoint, position) { + if (position.kind == "Break") { + const hits = await findHits(checkpoint, position); + if (hits.length) { + updateNearbyPoints(); + } } } @@ -669,6 +848,16 @@ function hasSteppingBreakpoint() { return gBreakpoints.some(bp => bp.kind == "EnterFrame" || bp.kind == "OnPop"); } +function findExistingFrameSteps(point) { + // Frame steps will include EnterFrame for both the initial and callee + // frames, so the same point can appear in two sets of steps. In this case + // the EnterFrame needs to be the first step. + if (point.position.kind == "EnterFrame") { + return gFrameSteps.find(steps => pointEquals(point, steps[0])); + } + return gFrameSteps.find(steps => pointArrayIncludes(steps, point)); +} + // Find all the steps in the frame which point is part of. This returns a // promise that resolves with the steps that were found. async function findFrameSteps(point) { @@ -682,93 +871,104 @@ async function findFrameSteps(point) { point.position.kind == "OnPop" ); - let steps = findExistingSteps(); + let steps = findExistingFrameSteps(point); if (steps) { return steps; } - const savedCheckpoint = getSavedCheckpoint(point.checkpoint); + // Gather information which the child which did the scan can use to figure out + // the different frame steps. + const info = gControl.sendRequestMainChild({ + type: "frameStepsInfo", + script: point.position.script, + }); - let entryPoint; - if (point.position.kind == "EnterFrame") { - entryPoint = point; - } else { - // The point is in the interior of the frame. Figure out the initial - // EnterFrame point for the frame. - const { - progress: targetProgress, - position: { script, frameIndex: targetFrameIndex }, - } = point; + const checkpoint = getSavedCheckpoint(point.checkpoint); + await scanRecording(checkpoint); + await sendAsyncManifest({ + shouldSkip: () => !!findExistingFrameSteps(point), + contents: () => ({ kind: "findFrameSteps", targetPoint: point, ...info }), + onFinished: (_, steps) => gFrameSteps.push(steps), + scanCheckpoint: checkpoint, + }); - // Find a position for the entry point of the frame. - const { firstBreakpointOffset } = gControl.sendRequestMainChild({ - type: "getScript", - id: script, - }); - const entryPosition = { - kind: "OnStep", - script, - offset: firstBreakpointOffset, - frameIndex: targetFrameIndex, - }; - - const entryHits = await findHits(savedCheckpoint, entryPosition); - - // Find the last hit on the entry position before the target point, which must - // be the entry point of the frame containing the target point. Since frames - // do not span checkpoints the hit must be in the range we are searching. Note - // that we are not dealing with async/generator frames very well here. - let progressAtFrameStart = 0; - for (const { - progress, - position: { frameIndex }, - } of entryHits) { - if ( - frameIndex == targetFrameIndex && - progress <= targetProgress && - progress > progressAtFrameStart - ) { - progressAtFrameStart = progress; - } - } - assert(progressAtFrameStart); - - // The progress at the initial offset should be the same as at the - // EnterFrame which pushed the frame onto the stack. No scripts should be - // able to run between these two points, though we don't have a way to check - // this. - entryPoint = { - checkpoint: point.checkpoint, - progress: progressAtFrameStart, - position: { kind: "EnterFrame" }, - }; - } - - const child = ownerChild(savedCheckpoint); - await child.sendManifestAsync( - { - shouldSkip: () => findExistingSteps() != null, - contents() { - return { kind: "findFrameSteps", entryPoint }; - }, - onFinished: (_, { frameSteps }) => gFrameSteps.push(frameSteps), - }, - entryPoint - ); - - steps = findExistingSteps(); + steps = findExistingFrameSteps(point); assert(steps); - return steps; - function findExistingSteps() { - // Frame steps will include EnterFrame for both the initial and callee - // frames, so the same point can appear in two sets of steps. In this case - // the EnterFrame needs to be the first step. - if (point.position.kind == "EnterFrame") { - return gFrameSteps.find(steps => pointEquals(point, steps[0])); - } - return gFrameSteps.find(steps => steps.some(p => pointEquals(point, p))); + updateNearbyPoints(); + + return steps; +} + +//////////////////////////////////////////////////////////////////////////////// +// Pause Data +//////////////////////////////////////////////////////////////////////////////// + +const gPauseData = new Map(); + +// Cached points indicate messages where we have gathered pause data. These are +// shown differently in the UI. +const gCachedPoints = new Map(); + +async function queuePauseData(point, trackCached, shouldSkipCallback) { + await waitForFlushed(point.checkpoint); + + sendAsyncManifest({ + shouldSkip() { + if (maybeGetPauseData(point)) { + return true; + } + + // If there is a logpoint at a position we will see a breakpoint as well. + // When the logpoint's text is resolved at this point then the pause data + // will be fetched as well. + if ( + gLogpoints.some(({ position }) => + positionSubsumes(position, point.position) + ) + ) { + return true; + } + + return shouldSkipCallback && shouldSkipCallback(); + }, + contents() { + return { kind: "getPauseData" }; + }, + onFinished(child, data) { + if (!data.restoredCheckpoint) { + addPauseData(point, data, trackCached); + child.divergedFromRecording = true; + } + }, + point, + expectedDuration: 250, + lowPriority: true, + }); +} + +function addPauseData(point, data, trackCached) { + if (data.paintData) { + // Atomize paint data strings to ensure that we don't store redundant + // strings for execution points with the same paint data. + data.paintData = RecordReplayControl.atomize(data.paintData); } + gPauseData.set(pointToString(point), data); + + if (trackCached) { + gCachedPoints.set(pointToString(point), point); + if (gDebugger) { + gDebugger._callOnPositionChange(); + } + } +} + +function maybeGetPauseData(point) { + return gPauseData.get(pointToString(point)); +} + +function cachedPoints() { + return [...gCachedPoints.values()].map(point => point.progress); } //////////////////////////////////////////////////////////////////////////////// @@ -784,7 +984,8 @@ const PauseModes = { // gActiveChild is paused at gPausePoint. PAUSED: "PAUSED", - // gActiveChild is being taken to gPausePoint, after which we will pause. + // gActiveChild is being taken to gPausePoint. The debugger is considered to + // be paused, but interacting with the child must wait until it arrives. ARRIVING: "ARRIVING", // gActiveChild is null, and we are looking for the last breakpoint hit prior @@ -802,6 +1003,10 @@ let gPausePoint = null; // In PAUSED mode, any debugger requests that have been sent to the child. const gDebuggerRequests = []; +// In PAUSED mode, whether gDebuggerRequests contains artificial requests that +// need to be synced with the child before new requests can be sent to it. +let gSyncDebuggerRequests = false; + function setPauseState(mode, point, child) { assert(mode); const idString = child ? ` #${child.id}` : ""; @@ -811,28 +1016,35 @@ function setPauseState(mode, point, child) { gPausePoint = point; gActiveChild = child; + if (mode != PauseModes.PAUSED) { + gDebuggerRequests.length = 0; + gSyncDebuggerRequests = false; + } + + if (mode == PauseModes.ARRIVING) { + updateNearbyPoints(); + } + pokeChildrenSoon(); } -// Asynchronously send a child to the specific point and pause the debugger. +// Mark the debugger as paused, and asynchronously send a child to the pause +// point. function setReplayingPauseTarget(point) { - setPauseState(PauseModes.ARRIVING, point, ownerChild(point.checkpoint)); - gDebuggerRequests.length = 0; + setPauseState(PauseModes.ARRIVING, point, closestChild(point.checkpoint)); + + gDebugger._onPause(); findFrameSteps(point); } // Synchronously send a child to the specific point and pause. -function pauseReplayingChild(point) { - const child = ownerChild(point.checkpoint); - +function pauseReplayingChild(child, point) { do { child.waitUntilPaused(); } while (maybeReachPoint(child, point)); setPauseState(PauseModes.PAUSED, point, child); - - findFrameSteps(point); } function sendChildToPausePoint(child) { @@ -846,10 +1058,9 @@ function sendChildToPausePoint(child) { case PauseModes.ARRIVING: if (pointEquals(child.pausePoint(), gPausePoint)) { setPauseState(PauseModes.PAUSED, gPausePoint, gActiveChild); - gDebugger._onPause(); - return; + } else { + maybeReachPoint(child, gPausePoint); } - maybeReachPoint(child, gPausePoint); return; default: @@ -857,6 +1068,23 @@ function sendChildToPausePoint(child) { } } +function waitUntilPauseFinishes() { + assert(gActiveChild); + + if (gActiveChild == gMainChild) { + gActiveChild.waitUntilPaused(true); + return; + } + + while (true) { + gActiveChild.waitUntilPaused(); + if (pointEquals(gActiveChild.pausePoint(), gPausePoint)) { + return; + } + pokeChild(gActiveChild); + } +} + // After the debugger resumes, find the point where it should pause next. async function finishResume() { assert( @@ -873,11 +1101,13 @@ async function finishResume() { let checkpoint = startCheckpoint; for (; ; forward ? checkpoint++ : checkpoint--) { - if (checkpoint == gMainChild.pauseCheckpoint()) { + if (checkpoint == gLastFlushCheckpoint) { // We searched the entire space forward to the end of the recording and // didn't find any breakpoint hits, so resume recording. assert(forward); - setPauseState(PauseModes.RUNNING, null, gMainChild); + RecordReplayControl.restoreMainGraphics(); + setPauseState(PauseModes.RUNNING, gMainChild.pausePoint(), gMainChild); + gDebugger._callOnPositionChange(); maybeResumeRecording(); return; } @@ -890,7 +1120,7 @@ async function finishResume() { return; } - if (!gCheckpoints[checkpoint].owner) { + if (!gCheckpoints[checkpoint].saved) { continue; } @@ -915,18 +1145,15 @@ async function finishResume() { ); } - if (forward) { - hits = hits.filter(p => pointPrecedes(gPausePoint, p)); - } else { - hits = hits.filter(p => pointPrecedes(p, gPausePoint)); - } - - if (hits.length) { + const hit = findClosestPoint( + hits, + gPausePoint, + /* before */ !forward, + /* inclusive */ false + ); + if (hit) { // We've found the point where the search should end. - hits.sort((a, b) => - forward ? pointPrecedes(b, a) : pointPrecedes(a, b) - ); - setReplayingPauseTarget(hits[0]); + setReplayingPauseTarget(hit); return; } } @@ -951,7 +1178,7 @@ function resume(forward) { } setPauseState( forward ? PauseModes.RESUMING_FORWARD : PauseModes.RESUMING_BACKWARD, - gActiveChild.pausePoint(), + gPausePoint, null ); finishResume(); @@ -961,13 +1188,115 @@ function resume(forward) { // Synchronously bring the active child to the specified execution point. function timeWarp(point) { setReplayingPauseTarget(point); - while (gPauseMode != PauseModes.PAUSED) { - gActiveChild.waitUntilPaused(); - pokeChildren(); - } Services.cpmm.sendAsyncMessage("TimeWarpFinished"); } +//////////////////////////////////////////////////////////////////////////////// +// Nearby Points +//////////////////////////////////////////////////////////////////////////////// + +// When the user is paused somewhere in the recording, we want to obtain pause +// data for points which they can get to via the UI. This includes all messages +// on the timeline (including those for logpoints), breakpoints that can be +// reached by rewinding and resuming, and points that can be reached by +// stepping. In the latter two cases, we only want to queue up the pause data +// for points that are close to the current pause point, so that we don't waste +// time and resources getting pause data that isn't immediately needed. These +// are the nearby points, which are updated when necessary after user +// interactions or when new steps or breakpoint hits are found. +// +// Ideally, as the user navigates through the recording, we will update the +// nearby points and fetch their pause data quick enough to avoid loading +// hiccups. + +let gNearbyPoints = []; + +// How many breakpoint hits are nearby points, on either side of the pause point. +const NumNearbyBreakpointHits = 2; + +// How many frame steps are nearby points, on either side of the pause point. +const NumNearbySteps = 4; + +function nextKnownBreakpointHit(point, forward) { + let checkpoint = getSavedCheckpoint(point.checkpoint); + for (; ; forward ? checkpoint++ : checkpoint--) { + if ( + checkpoint == gLastFlushCheckpoint || + checkpoint == InvalidCheckpointId + ) { + return null; + } + + if (!gCheckpoints[checkpoint].saved) { + continue; + } + + let hits = []; + + // Find any breakpoint hits in this region of the recording. + for (const bp of gBreakpoints) { + if (canFindHits(bp)) { + const bphits = findExistingHits(checkpoint, bp); + if (bphits) { + hits = hits.concat(bphits); + } + } + } + + const hit = findClosestPoint( + hits, + gPausePoint, + /* before */ !forward, + /* inclusive */ false + ); + if (hit) { + return hit; + } + } +} + +function nextKnownBreakpointHits(point, forward, count) { + const rv = []; + for (let i = 0; i < count; i++) { + const next = nextKnownBreakpointHit(point, forward); + if (next) { + rv.push(next); + point = next; + } else { + break; + } + } + return rv; +} + +function updateNearbyPoints() { + const nearby = [ + ...nextKnownBreakpointHits(gPausePoint, true, NumNearbyBreakpointHits), + ...nextKnownBreakpointHits(gPausePoint, false, NumNearbyBreakpointHits), + ]; + + const steps = gPausePoint.position && findExistingFrameSteps(gPausePoint); + if (steps) { + // Nearby steps are included in the nearby points. Do not include the first + // point in any frame steps we find --- these are EnterFrame points which + // will not be reverse-stepped to. + const index = steps.findIndex(point => pointEquals(point, gPausePoint)); + const start = Math.max(index - NumNearbySteps, 1); + nearby.push(...steps.slice(start, index + NumNearbySteps - start)); + } + + // Start gathering pause data for any new nearby points. + for (const point of nearby) { + if (!pointArrayIncludes(gNearbyPoints, point)) { + queuePauseData(point, /* trackCached */ false, () => { + return !pointArrayIncludes(gNearbyPoints, point); + }); + } + } + + gNearbyPoints = nearby; +} + //////////////////////////////////////////////////////////////////////////////// // Logpoints //////////////////////////////////////////////////////////////////////////////// @@ -985,22 +1314,26 @@ async function findLogpointHits( { position, text, condition, callback } ) { const hits = await findHits(checkpoint, position); - const child = ownerChild(checkpoint); for (const point of hits) { - await child.sendManifestAsync( - { - contents() { - return { kind: "hitLogpoint", text, condition }; - }, - onFinished(child, { result }) { - if (result) { - callback(point, gDebugger._convertCompletionValue(result)); - } - child.divergedFromRecording = true; - }, + if (!condition) { + callback(point, { return: "Loading..." }); + } + sendAsyncManifest({ + shouldSkip: () => false, + contents() { + return { kind: "hitLogpoint", text, condition }; }, - point - ); + onFinished(child, { data, result }) { + if (result) { + addPauseData(point, data, /* trackCached */ true); + callback(point, gDebugger._convertCompletionValue(result)); + } + child.divergedFromRecording = true; + }, + point, + expectedDuration: 250, + lowPriority: true, + }); } } @@ -1028,6 +1361,12 @@ function handleResumeManifestResponse({ if (gDebugger) { scripts.forEach(script => gDebugger._onNewScript(script)); } + + consoleMessages.forEach(msg => { + if (msg.executionPoint) { + queuePauseData(msg.executionPoint, /* trackCached */ true); + } + }); } // If necessary, continue executing in the main child. @@ -1036,7 +1375,10 @@ function maybeResumeRecording() { return; } - if (timeSinceCheckpoint(gLastFlushCheckpoint) >= FlushMs) { + if ( + !gLastFlushCheckpoint || + timeSinceCheckpoint(gLastFlushCheckpoint) >= FlushMs + ) { ensureFlushed(); } @@ -1064,11 +1406,27 @@ function maybeResumeRecording() { }); } +// Resolve callbacks for any promises waiting on the recording to be flushed. +const gFlushWaiters = []; + +function waitForFlushed(checkpoint) { + if (checkpoint < gLastFlushCheckpoint) { + return undefined; + } + return new Promise(resolve => { + gFlushWaiters.push(resolve); + }); +} + +let gLastFlushTime = Date.now(); + // If necessary, synchronously flush the recording to disk. function ensureFlushed() { assert(gActiveChild == gMainChild); gMainChild.waitUntilPaused(true); + gLastFlushTime = Date.now(); + if (gLastFlushCheckpoint == gActiveChild.pauseCheckpoint()) { return; } @@ -1102,15 +1460,37 @@ function ensureFlushed() { checkpoint => { scanRecording(checkpoint); - // Scan for breakpoint and search hits in this new region. - gBreakpoints.forEach(position => findHits(checkpoint, position)); + // Scan for breakpoint and logpoint hits in this new region. + gBreakpoints.forEach(position => + findBreakpointHits(checkpoint, position) + ); gLogpoints.forEach(logpoint => findLogpointHits(checkpoint, logpoint)); } ); + for (const waiter of gFlushWaiters) { + waiter(); + } + gFlushWaiters.length = 0; + pokeChildren(); } +const CheckFlushMs = 1000; + +// Periodically make sure the recording is flushed. If the tab is sitting +// idle we still want to keep the recording up to date. +setInterval(() => { + const elapsed = Date.now() - gLastFlushTime; + if ( + elapsed > CheckFlushMs && + gMainChild.lastPausePoint && + gMainChild.lastPausePoint.checkpoint != gLastFlushCheckpoint + ) { + ensureFlushed(); + } +}, CheckFlushMs); + // eslint-disable-next-line no-unused-vars function BeforeSaveRecording() { if (gActiveChild == gMainChild) { @@ -1193,9 +1573,17 @@ function ManifestFinished(id, response) { const gControl = { // Get the current point where the active child is paused, or null. pausePoint() { - return gActiveChild && gActiveChild.paused - ? gActiveChild.pausePoint() - : null; + if (gActiveChild && gActiveChild == gMainChild) { + return gActiveChild.paused ? gActiveChild.pausePoint() : null; + } + if (gPauseMode == PauseModes.PAUSED || gPauseMode == PauseModes.ARRIVING) { + return gPausePoint; + } + return null; + }, + + lastPausePoint() { + return gPausePoint; }, // Return whether the active child is currently recording. @@ -1210,15 +1598,6 @@ const gControl = { if (gActiveChild == gMainChild) { gActiveChild.waitUntilPaused(true); - return; - } - - while (true) { - gActiveChild.waitUntilPaused(); - if (pointEquals(gActiveChild.pausePoint(), gPausePoint)) { - return; - } - pokeChild(gActiveChild); } }, @@ -1231,9 +1610,7 @@ const gControl = { forSavedCheckpointsInRange( FirstCheckpointId, gLastFlushCheckpoint, - checkpoint => { - findHits(checkpoint, position); - } + checkpoint => findBreakpointHits(checkpoint, position) ); } @@ -1242,6 +1619,8 @@ const gControl = { // next checkpoint, so force it to create a checkpoint now. gActiveChild.waitUntilPaused(true); } + + updateNearbyPoints(); }, // Clear all installed breakpoints. @@ -1252,6 +1631,7 @@ const gControl = { // child immediately. gActiveChild.waitUntilPaused(true); } + updateNearbyPoints(); }, // Get the last known point in the recording. @@ -1262,7 +1642,7 @@ const gControl = { // If the active child is currently recording, switch to a replaying one if // possible. maybeSwitchToReplayingChild() { - assert(gActiveChild.paused); + assert(gControl.pausePoint()); if (gActiveChild == gMainChild && RecordReplayControl.canRewind()) { const point = gActiveChild.pausePoint(); @@ -1279,13 +1659,27 @@ const gControl = { } ensureFlushed(); - pauseReplayingChild(point); + const child = closestChild(point); + pauseReplayingChild(child, point); } }, // Synchronously send a debugger request to a paused active child, returning // the response. sendRequest(request) { + waitUntilPauseFinishes(); + + if (gSyncDebuggerRequests) { + gActiveChild.sendManifest({ + contents: { kind: "batchDebuggerRequest", requests: gDebuggerRequests }, + onFinished(finishData) { + assert(!finishData || !finishData.restoredCheckpoint); + }, + }); + gActiveChild.waitUntilPaused(); + gSyncDebuggerRequests = false; + } + let data; gActiveChild.sendManifest({ contents: { kind: "debuggerRequest", request }, @@ -1299,7 +1693,7 @@ const gControl = { // The child had an unhandled recording diverge and restored an earlier // checkpoint. Restore the child to the point it should be paused at and // fill its paused state back in by resending earlier debugger requests. - pauseReplayingChild(gPausePoint); + pauseReplayingChild(gActiveChild, gPausePoint); gActiveChild.sendManifest({ contents: { kind: "batchDebuggerRequest", requests: gDebuggerRequests }, onFinished(finishData) { @@ -1348,6 +1742,52 @@ const gControl = { checkpoint => findLogpointHits(checkpoint, logpoint) ); }, + + unscannedRegions, + cachedPoints, + + getPauseData() { + if (!gDebuggerRequests.length) { + assert(!gSyncDebuggerRequests); + const data = maybeGetPauseData(gPausePoint); + if (data) { + gSyncDebuggerRequests = true; + gDebuggerRequests.push({ type: "pauseData" }); + return data; + } + } + gControl.maybeSwitchToReplayingChild(); + return gControl.sendRequest({ type: "pauseData" }); + }, + + repaint() { + if (!gPausePoint) { + return; + } + if ( + gMainChild.paused && + pointEquals(gPausePoint, gMainChild.pausePoint()) + ) { + // Flush the recording if we are repainting because we interrupted things + // and will now rewind. + if (gMainChild.recording) { + ensureFlushed(); + } + return; + } + const data = maybeGetPauseData(gPausePoint); + if (data && data.paintData) { + RecordReplayControl.hadRepaint(data.paintData); + } else { + gControl.maybeSwitchToReplayingChild(); + const rv = gControl.sendRequest({ type: "repaint" }); + if (rv && rv.length) { + RecordReplayControl.hadRepaint(rv); + } else { + RecordReplayControl.clearGraphics(); + } + } + }, }; /////////////////////////////////////////////////////////////////////////////// diff --git a/devtools/server/actors/replay/debugger.js b/devtools/server/actors/replay/debugger.js index c02acbaad475..558db91d933b 100644 --- a/devtools/server/actors/replay/debugger.js +++ b/devtools/server/actors/replay/debugger.js @@ -103,8 +103,7 @@ ReplayDebugger.prototype = { canRewind: RecordReplayControl.canRewind, replayCurrentExecutionPoint() { - this._ensurePaused(); - return this._control.pausePoint(); + return this._control.lastPausePoint(); }, replayRecordingEndpoint() { @@ -115,6 +114,14 @@ ReplayDebugger.prototype = { return this._control.childIsRecording(); }, + replayUnscannedRegions() { + return this._control.unscannedRegions(); + }, + + replayCachedPoints() { + return this._control.cachedPoints(); + }, + addDebuggee() {}, removeAllDebuggees() {}, @@ -158,18 +165,6 @@ ReplayDebugger.prototype = { return this._processResponse(request, response); }, - // 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(); - } - }, - getDebuggees() { return []; }, @@ -310,10 +305,10 @@ ReplayDebugger.prototype = { } }, - // 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. + // This hook is called whenever control state changes which affects something + // the position change handler listens to (more than just position changes, + // alas). + _callOnPositionChange() { if (this.replayingOnPositionChange) { this.replayingOnPositionChange(); } @@ -329,7 +324,7 @@ ReplayDebugger.prototype = { this._direction = Direction.NONE; // Update graphics according to the current state of the child. - this._repaint(); + this._control.repaint(); // If breakpoint handlers for the pause haven't been called yet, don't // call them at all. @@ -362,7 +357,6 @@ ReplayDebugger.prototype = { _performResume() { this._ensurePaused(); - assert(!this._threadPauseCount); if (this._resumeCallback && !this._threadPauseCount) { const callback = this._resumeCallback; this._invalidateAfterUnpause(); @@ -387,7 +381,7 @@ ReplayDebugger.prototype = { return; } - const pauseData = this._sendRequestAllowDiverge({ type: "pauseData" }); + const pauseData = this._control.getPauseData(); if (!pauseData.frames) { return; } @@ -423,6 +417,7 @@ ReplayDebugger.prototype = { }, _virtualConsoleLog(position, text, condition, callback) { + dumpv(`AddLogpoint ${JSON.stringify(position)} ${text} ${condition}`); this._control.addLogpoint({ position, text, condition, callback }); }, @@ -431,7 +426,7 @@ ReplayDebugger.prototype = { ///////////////////////////////////////////////////////// _setBreakpoint(handler, position, data) { - dumpv("AddBreakpoint " + JSON.stringify(position)); + dumpv(`AddBreakpoint ${JSON.stringify(position)}`); this._control.addBreakpoint(position); this._breakpoints.push({ handler, position, data }); }, @@ -709,6 +704,7 @@ ReplayDebugger.prototype = { }, set onEnterFrame(handler) { this._breakpointKindSetter("EnterFrame", handler, () => { + this._capturePauseData(); handler.call(this, this.getNewestFrame()); }); }, @@ -880,9 +876,7 @@ function ReplayDebuggerFrame(dbg, data) { this._dbg = dbg; this._data = data; if (this._data.arguments) { - this._data.arguments = this._data.arguments.map(a => - this._dbg._convertValue(a) - ); + this._arguments = this._data.arguments.map(a => this._dbg._convertValue(a)); } } @@ -916,7 +910,8 @@ ReplayDebuggerFrame.prototype = { return this._data.offset; }, get arguments() { - return this._data.arguments; + assert(this._data); + return this._arguments; }, get live() { return true; diff --git a/devtools/server/actors/replay/graphics.js b/devtools/server/actors/replay/graphics.js index a4fd52487708..de28dc60dae8 100644 --- a/devtools/server/actors/replay/graphics.js +++ b/devtools/server/actors/replay/graphics.js @@ -45,7 +45,7 @@ function getCanvas(window) { return window.middlemanCanvas; } -function updateWindowCanvas(window, buffer, width, height, hadFailure) { +function updateWindowCanvas(window, buffer, width, height) { // Make sure the window has a canvas filling the screen. const canvas = getCanvas(window); @@ -67,29 +67,39 @@ function updateWindowCanvas(window, buffer, width, height, hadFailure) { const imageData = cx.getImageData(0, 0, width, height); imageData.data.set(graphicsData); cx.putImageData(imageData, 0, 0); +} - // Indicate to the user when repainting failed and we are showing old painted - // graphics instead of the most up-to-date graphics. - if (hadFailure) { - cx.fillStyle = "red"; - cx.font = "48px serif"; - cx.fillText("PAINT FAILURE", 10, 50); - } +function clearWindowCanvas(window) { + const canvas = getCanvas(window); + + const cx = canvas.getContext("2d"); + cx.clearRect(0, 0, canvas.width, canvas.height); } // Entry point for when we have some new graphics data from the child process // to draw. // eslint-disable-next-line no-unused-vars -function UpdateCanvas(buffer, width, height, hadFailure) { +function UpdateCanvas(buffer, width, height) { try { // Paint to all windows we can find. Hopefully there is only one. for (const window of Services.ww.getWindowEnumerator()) { - updateWindowCanvas(window, buffer, width, height, hadFailure); + updateWindowCanvas(window, buffer, width, height); } } catch (e) { - dump("Middleman Graphics UpdateCanvas Exception: " + e + "\n"); + dump(`Middleman Graphics UpdateCanvas Exception: ${e}\n`); } } // eslint-disable-next-line no-unused-vars -var EXPORTED_SYMBOLS = ["UpdateCanvas"]; +function ClearCanvas() { + try { + for (const window of Services.ww.getWindowEnumerator()) { + clearWindowCanvas(window); + } + } catch (e) { + dump(`Middleman Graphics ClearCanvas Exception: ${e}\n`); + } +} + +// eslint-disable-next-line no-unused-vars +var EXPORTED_SYMBOLS = ["UpdateCanvas", "ClearCanvas"]; diff --git a/devtools/server/actors/replay/replay.js b/devtools/server/actors/replay/replay.js index 5976fef2e5f7..44e17f1ba437 100644 --- a/devtools/server/actors/replay/replay.js +++ b/devtools/server/actors/replay/replay.js @@ -35,6 +35,7 @@ const sandbox = Cu.Sandbox( Cu.evalInSandbox( "Components.utils.import('resource://gre/modules/jsdebugger.jsm');" + "Components.utils.import('resource://gre/modules/Services.jsm');" + + "Components.utils.import('resource://devtools/shared/execution-point-utils.js');" + "addDebuggerToGlobal(this);", sandbox ); @@ -44,15 +45,22 @@ const { Services, InspectorUtils, CSSRule, + pointPrecedes, + pointEquals, + findClosestPoint, } = sandbox; const dbg = new Debugger(); -const firstGlobal = dbg.makeGlobalObjectReference(sandbox); +const gFirstGlobal = dbg.makeGlobalObjectReference(sandbox); +const gAllGlobals = []; // We are interested in debugging all globals in the process. dbg.onNewGlobalObject = function(global) { try { dbg.addDebuggee(global); + gAllGlobals.push(global); + + scanningOnNewGlobal(global); } catch (e) { // Ignore errors related to adding a same-compartment debuggee. // See bug 1523755. @@ -181,7 +189,8 @@ const gScripts = new IdMap(); const gNewScripts = []; function addScript(script) { - gScripts.add(script); + const id = gScripts.add(script); + script.setInstrumentationId(id); script.getChildScripts().forEach(addScript); } @@ -204,6 +213,11 @@ function considerScript(script) { return RecordReplayControl.shouldUpdateProgressCounter(script.url); } +function setEmptyInstrumentationId(script) { + script.setInstrumentationId(0); + script.getChildScripts().foreach(setEmptyInstrumentationId); +} + dbg.onNewScript = function(script) { if (RecordReplayControl.areThreadEventsDisallowed()) { // This script is part of an eval on behalf of the debugger. @@ -211,17 +225,13 @@ dbg.onNewScript = function(script) { } if (!considerScript(script)) { + setEmptyInstrumentationId(script); return; } addScript(script); addScriptSource(script.source); - // Each onNewScript call advances the progress counter, to preserve the - // ProgressCounter invariant when onNewScript is called multiple times - // without executing any scripts. - RecordReplayControl.advanceProgressCounter(); - if (gManifest.kind == "resume") { gNewScripts.push(getScriptData(gScripts.getId(script))); } @@ -372,36 +382,225 @@ function NewTimeWarpTarget() { // Recording Scanning /////////////////////////////////////////////////////////////////////////////// -const gScannedScripts = new Set(); +// The recording is scanned using the Debugger's instrumentation API. We need to +// accumulate the execution points at which every breakpoint site is hit, and +// use instrumentation both to invoke a callback at those breakpoint site hits +// and to efficiently update the frame depth when no generators/async frames +// or exception unwinds occur. In the latter case we fallback on the Debugger +// API to make sure we maintain the correct frame depth. +// +// The Debugger API can also straightforwardly provide this information, +// by setting EnterFrame/OnPop hooks and breakpoints on all appropriate sites +// in content scripts. Unfortunately, this is extremely slow: setting a single +// breakpoint in a script prevents it from being Ion compiled and causes it to +// run several times slower than the normal baseline code. If the page being +// debugged has much JS, scanning it will be extremely slow compared to the +// normal execution speed, and many replaying processes will be needed to keep +// scan data up to date. -function startScanningScript(script) { - const id = gScripts.getId(script); - const offsets = script.getPossibleBreakpointOffsets(); - let lastFrame = null, - lastFrameIndex = 0; - for (const offset of offsets) { - const handler = { - hit(frame) { - let frameIndex; - if (frame == lastFrame) { - frameIndex = lastFrameIndex; - } else { - lastFrame = frame; - lastFrameIndex = frameIndex = countScriptFrames() - 1; - } - RecordReplayControl.addScriptHit(id, offset, frameIndex); - }, - }; - script.setBreakpoint(offset, handler); +function scanningOnNewGlobal(global) { + global.setInstrumentation( + global.makeDebuggeeNativeFunction( + RecordReplayControl.instrumentationCallback + ), + ["main", "entry", "breakpoint", "exit"] + ); + + if (RecordReplayControl.isScanningScripts()) { + global.setInstrumentationActive(true); } } +// eslint-disable-next-line no-unused-vars +function ScriptResumeFrame(script) { + // At frame resumption points, sync the frame depth. These won't be hit very + // often, and handling them is tricky when e.g. catching exceptions thrown by + // an await, which could be either a resumption or a continuation of an + // existing frame. + RecordReplayControl.setFrameDepth(countScriptFrames() - 1); + RecordReplayControl.onResumeFrame("", script); +} + +function startScanningAllScripts() { + if (RecordReplayControl.isScanningScripts()) { + return; + } + RecordReplayControl.setScanningScripts(true); + + for (const global of gAllGlobals) { + global.setInstrumentationActive(true); + } + + // The onExceptionUnwind hook gets called anytime an error needs to be handled + // for a frame. If there are try/catch or try/finally blocks in the script + // then the hook might be called multiple times, and the frame might finish + // normally. To avoid dealing with this complexity we just add an onPop hook + // to any frame that has had exceptions unwound in it, to make sure the frame + // index is set correctly when it finally unwinds. + dbg.onExceptionUnwind = frame => { + if (considerScript(frame.script)) { + frame.onPop = () => { + const script = gScripts.getId(frame.script); + RecordReplayControl.setFrameDepth(countScriptFrames()); + RecordReplayControl.onExitFrame("", script); + }; + } + }; +} + +function stopScanningAllScripts() { + if (!RecordReplayControl.isScanningScripts()) { + return; + } + RecordReplayControl.setScanningScripts(false); + + for (const global of gAllGlobals) { + global.setInstrumentationActive(false); + } + + dbg.onExceptionUnwind = undefined; +} + +/////////////////////////////////////////////////////////////////////////////// +// Scanning Queries +/////////////////////////////////////////////////////////////////////////////// + +function findScriptHits(position, startpoint, endpoint) { + const { kind, script, offset, frameIndex: bpFrameIndex } = position; + const hits = []; + for (let checkpoint = startpoint; checkpoint < endpoint; checkpoint++) { + const allHits = RecordReplayControl.findScriptHits( + checkpoint, + script, + offset + ); + for (const { progress, frameIndex } of allHits) { + switch (kind) { + case "OnStep": + if (bpFrameIndex != frameIndex) { + continue; + } + // FALLTHROUGH + case "Break": + hits.push({ + checkpoint, + progress, + position: { kind: "OnStep", script, offset, frameIndex }, + }); + } + } + } + return hits; +} + +function findAllScriptHits(script, frameIndex, offsets, startpoint, endpoint) { + const allHits = []; + for (const offset of offsets) { + const position = { + kind: "OnStep", + script, + offset, + frameIndex, + }; + + const hits = findScriptHits(position, startpoint, endpoint); + allHits.push(...hits); + } + return allHits; +} + +function findChangeFrames(checkpoint, which, kind, frameIndex, maybeScript) { + const hits = RecordReplayControl.findChangeFrames(checkpoint, which); + return hits + .filter( + hit => + hit.frameIndex == frameIndex && + (!maybeScript || hit.script == maybeScript) + ) + .map(({ script, progress }) => ({ + checkpoint, + progress, + position: { kind, script, frameIndex }, + })); +} + +function findFrameSteps({ targetPoint, breakpointOffsets }) { + const { + checkpoint, + position: { script, frameIndex: targetIndex }, + } = targetPoint; + + // Find the entry point of the frame whose steps contain |targetPoint|. + let entryPoint; + if (targetPoint.position.kind == "EnterFrame") { + entryPoint = targetPoint; + } else { + const entryHits = [ + ...findChangeFrames(checkpoint, 0, "EnterFrame", targetIndex, script), + ...findChangeFrames(checkpoint, 2, "EnterFrame", targetIndex, script), + ]; + + // Find the last frame entry or resume for the frame's script preceding the + // target point. Since frames do not span checkpoints the hit must be in the + // range we are searching. + entryPoint = findClosestPoint( + entryHits, + targetPoint, + /* before */ true, + /* inclusive */ true + ); + assert(entryPoint); + } + + // Find the exit point of the frame. + const exitHits = findChangeFrames( + checkpoint, + 1, + "OnPop", + targetIndex, + script + ); + const exitPoint = findClosestPoint( + exitHits, + targetPoint, + /* before */ false, + /* inclusive */ true + ); + + // The steps in the frame are the hits in the script which have the right + // frame index and happen between the entry and exit points. Any EnterFrame + // points for immediate callees of the frame are also included. + const breakpointHits = findAllScriptHits( + script, + targetIndex, + breakpointOffsets, + checkpoint, + checkpoint + 1 + ); + const enterFrameHits = findChangeFrames( + checkpoint, + 0, + "EnterFrame", + targetIndex + 1 + ); + const steps = breakpointHits.concat(enterFrameHits).filter(point => { + return pointPrecedes(entryPoint, point) && pointPrecedes(point, exitPoint); + }); + steps.push(entryPoint, exitPoint); + + steps.sort((pointA, pointB) => { + return pointPrecedes(pointB, pointA); + }); + + return steps; +} + /////////////////////////////////////////////////////////////////////////////// // Position Handler State /////////////////////////////////////////////////////////////////////////////// -// Position kinds we are expected to hit. -let gPositionHandlerKinds = Object.create(null); +// Whether EnterFrame positions should be hit. +let gHasEnterFrameHandler = false; // Handlers we tried to install but couldn't due to a script not existing. // Breakpoints requested by the middleman --- which are preserved when @@ -421,7 +620,7 @@ function clearPositionHandlers() { dbg.clearAllBreakpoints(); dbg.onEnterFrame = undefined; - gPositionHandlerKinds = Object.create(null); + gHasEnterFrameHandler = false; gPendingPcHandlers.length = 0; gInstalledPcHandlers.length = 0; gOnPopFilters.length = 0; @@ -434,14 +633,6 @@ function installPendingHandlers() { pending.forEach(ensurePositionHandler); } -// Hit a position with the specified kind if we are expected to. This is for -// use with position kinds that have no script/offset/frameIndex information. -function hitGlobalHandler(kind, frame) { - if (gPositionHandlerKinds[kind]) { - positionHit({ kind }, frame); - } -} - // The completion state of any frame that is being popped. let gPopFrameResult = null; @@ -457,7 +648,14 @@ function onPopFrame(completion) { function onEnterFrame(frame) { if (considerScript(frame.script)) { - hitGlobalHandler("EnterFrame", frame); + if (gHasEnterFrameHandler) { + ensurePositionHandler({ + kind: "OnStep", + script: gScripts.getId(frame.script), + frameIndex: countScriptFrames() - 1, + offset: frame.script.mainOffset, + }); + } gOnPopFilters.forEach(filter => { if (filter(frame)) { @@ -481,8 +679,6 @@ function addOnPopFilter(filter) { } function ensurePositionHandler(position) { - gPositionHandlerKinds[position.kind] = true; - switch (position.kind) { case "Break": case "OnStep": @@ -496,6 +692,12 @@ function ensurePositionHandler(position) { gPendingPcHandlers.push(position); return; } + + // Make sure the script is delazified and has been instrumented before + // we try to operate on it, so that we can compute the appropriate offsets + // to use. Accessing mainOffset here is a hack but ensures the script is + // not lazy. + debugScript.mainOffset; } const match = function({ script, offset }) { @@ -511,6 +713,14 @@ function ensurePositionHandler(position) { debugScript.setBreakpoint(position.offset, { hit(frame) { + if (position.offset == debugScript.mainOffset) { + positionHit({ + kind: "EnterFrame", + script: position.script, + frameIndex: countScriptFrames() - 1, + }); + } + positionHit( { kind: "OnStep", @@ -529,6 +739,7 @@ function ensurePositionHandler(position) { break; case "EnterFrame": dbg.onEnterFrame = onEnterFrame; + gHasEnterFrameHandler = true; break; } } @@ -630,7 +841,7 @@ function makeDebuggeeValue(value) { // Sometimes the global which Cu.getGlobalForObject finds has // isInvisibleToDebugger set. Wrap the object into the first global we // found in this case. - return firstGlobal.makeDebuggeeValue(value); + return gFirstGlobal.makeDebuggeeValue(value); } } return value; @@ -688,43 +899,13 @@ const gManifestStartHandlers = { }, findHits({ position, startpoint, endpoint }) { - const { kind, script, offset, frameIndex: bpFrameIndex } = position; - const hits = []; - const allHits = RecordReplayControl.findScriptHits(script, offset); - for (const { checkpoint, progress, frameIndex } of allHits) { - if (checkpoint >= startpoint && checkpoint < endpoint) { - switch (kind) { - case "OnStep": - if (bpFrameIndex != frameIndex) { - continue; - } - // FALLTHROUGH - case "Break": - hits.push({ - checkpoint, - progress, - position: { kind: "OnStep", script, offset, frameIndex }, - }); - } - } - } - RecordReplayControl.manifestFinished(hits); + RecordReplayControl.manifestFinished( + findScriptHits(position, startpoint, endpoint) + ); }, - findFrameSteps({ entryPoint }) { - assert(entryPoint.position.kind == "EnterFrame"); - const frameIndex = countScriptFrames() - 1; - const script = getFrameData(frameIndex).script; - const offsets = gScripts.getObject(script).getPossibleBreakpointOffsets(); - for (const offset of offsets) { - ensurePositionHandler({ kind: "OnStep", script, offset, frameIndex }); - } - ensurePositionHandler({ kind: "EnterFrame" }); - ensurePositionHandler({ kind: "OnPop", script, frameIndex }); - - gFrameSteps = [entryPoint]; - gFrameStepsFrameIndex = frameIndex; - RecordReplayControl.resumeExecution(); + findFrameSteps(info) { + RecordReplayControl.manifestFinished(findFrameSteps(info)); }, flushRecording() { @@ -752,6 +933,13 @@ const gManifestStartHandlers = { RecordReplayControl.manifestFinished(); }, + getPauseData() { + divergeFromRecording(); + const data = getPauseData(); + data.paintData = RecordReplayControl.repaint(); + RecordReplayControl.manifestFinished(data); + }, + hitLogpoint({ text, condition }) { divergeFromRecording(); @@ -766,7 +954,11 @@ const gManifestStartHandlers = { const rv = frame.eval(text); const converted = convertCompletionValue(rv, { snapshot: true }); - RecordReplayControl.manifestFinished({ result: converted }); + + const data = getPauseData(); + data.paintData = RecordReplayControl.repaint(); + + RecordReplayControl.manifestFinished({ result: converted, data }); }, }; @@ -788,7 +980,7 @@ function ManifestStart(manifest) { // eslint-disable-next-line no-unused-vars function BeforeCheckpoint() { clearPositionHandlers(); - gScannedScripts.clear(); + stopScanningAllScripts(); } const FirstCheckpointId = 1; @@ -865,12 +1057,7 @@ const gManifestPrepareAfterCheckpointHandlers = { }, scanRecording() { - dbg.onEnterFrame = frame => { - if (considerScript(frame.script) && !gScannedScripts.has(frame.script)) { - startScanningScript(frame.script); - gScannedScripts.add(frame.script); - } - }; + startScanningAllScripts(); }, }; @@ -878,10 +1065,7 @@ function processManifestAfterCheckpoint(point, restoredCheckpoint) { // After rewinding gManifest won't be correct, so we always mark the current // manifest as finished and rely on the middleman to give us a new one. if (restoredCheckpoint) { - RecordReplayControl.manifestFinished({ - restoredCheckpoint, - point: currentExecutionPoint(), - }); + RecordReplayControl.manifestFinished({ restoredCheckpoint, point }); } if (!gManifest) { @@ -911,11 +1095,6 @@ function AfterCheckpoint(id, restoredCheckpoint) { } } -// In the findFrameSteps manifest, all steps that have been found. -let gFrameSteps = null; - -let gFrameStepsFrameIndex = 0; - // Handlers that run after reaching a position watched by ensurePositionHandler. // This must be specified for any manifest that uses ensurePositionHandler. const gManifestPositionHandlers = { @@ -929,35 +1108,11 @@ const gManifestPositionHandlers = { }, runToPoint({ endpoint }, point) { - if ( - point.progress == endpoint.progress && - point.position.frameIndex == endpoint.position.frameIndex - ) { + if (pointEquals(point, endpoint)) { clearPositionHandlers(); RecordReplayControl.manifestFinished({ point }); } }, - - findFrameSteps(_, point) { - switch (point.position.kind) { - case "OnStep": - gFrameSteps.push(point); - break; - case "EnterFrame": - if (countScriptFrames() == gFrameStepsFrameIndex + 2) { - gFrameSteps.push(point); - } - break; - case "OnPop": - gFrameSteps.push(point); - clearPositionHandlers(); - RecordReplayControl.manifestFinished({ - point, - frameSteps: gFrameSteps, - }); - break; - } - }, }; function positionHit(position, frame) { @@ -986,7 +1141,6 @@ function getScriptData(id) { displayName: script.displayName, url: script.url, format: script.format, - firstBreakpointOffset: script.getPossibleBreakpointOffsets()[0], }; } @@ -1023,6 +1177,7 @@ function getFrameData(index) { } } + const script = gScripts.getId(frame.script); return { index, type: frame.type, @@ -1031,7 +1186,7 @@ function getFrameData(index) { generator: frame.generator, constructing: frame.constructing, this: convertValue(frame.this), - script: gScripts.getId(frame.script), + script, offset: frame.offset, arguments: _arguments, }; @@ -1290,13 +1445,14 @@ function getPauseData() { } for (let i = 0; i < numFrames; i++) { + const dbgFrame = scriptFrameForIndex(i); const frame = getFrameData(i); const script = gScripts.getObject(frame.script); rv.frames.push(frame); rv.offsetMetadata.push({ scriptId: frame.script, offset: frame.offset, - metadata: script.getOffsetMetadata(frame.offset), + metadata: script.getOffsetMetadata(dbgFrame.offset), }); addScript(frame.script); addValue(frame.this, true); @@ -1429,6 +1585,13 @@ const gRequestHandlers = { getPossibleBreakpoints: forwardToScript("getPossibleBreakpoints"), getPossibleBreakpointOffsets: forwardToScript("getPossibleBreakpointOffsets"), + frameStepsInfo(request) { + const script = gScripts.getObject(request.script); + return { + breakpointOffsets: script.getPossibleBreakpointOffsets(), + }; + }, + frameEvaluate(request) { divergeFromRecording(); const frame = scriptFrameForIndex(request.index); @@ -1540,4 +1703,5 @@ var EXPORTED_SYMBOLS = [ "BeforeCheckpoint", "AfterCheckpoint", "NewTimeWarpTarget", + "ScriptResumeFrame", ]; diff --git a/devtools/server/actors/replay/rrIGraphics.idl b/devtools/server/actors/replay/rrIGraphics.idl index 04c2ee8576b3..dde83fbb1a94 100644 --- a/devtools/server/actors/replay/rrIGraphics.idl +++ b/devtools/server/actors/replay/rrIGraphics.idl @@ -7,7 +7,7 @@ [scriptable, uuid(941b2e20-8558-4881-b5ad-dc3a1f2d9678)] interface rrIGraphics : nsISupports { - void UpdateCanvas(in jsval buffer, in long width, in long height, - in boolean hadFailure); + void UpdateCanvas(in jsval buffer, in long width, in long height); + void ClearCanvas(); }; diff --git a/devtools/server/actors/replay/rrIReplay.idl b/devtools/server/actors/replay/rrIReplay.idl index 30ec50826c03..bedc4a235f69 100644 --- a/devtools/server/actors/replay/rrIReplay.idl +++ b/devtools/server/actors/replay/rrIReplay.idl @@ -14,4 +14,5 @@ interface rrIReplay : nsISupports { void BeforeCheckpoint(); void AfterCheckpoint(in long checkpoint, in bool restoredCheckpoint); long NewTimeWarpTarget(); + void ScriptResumeFrame(in long script); }; diff --git a/devtools/shared/execution-point-utils.js b/devtools/shared/execution-point-utils.js index 3fdd74146b58..8b1391e547f3 100644 --- a/devtools/shared/execution-point-utils.js +++ b/devtools/shared/execution-point-utils.js @@ -17,13 +17,13 @@ // "OnPop": Break when a script's frame with a given frame depth is popped. // "EnterFrame": Break when any script is entered. // -// script: For all kinds but "EnterFrame", the ID of the position's script. +// script: The ID of the position's script. This is optional for "EnterFrame". // // offset: For "Break" and "OnStep", the offset within the script. // -// frameIndex: For "OnStep" and "OnPop", the index of the topmost script frame. -// Indexes start at zero for the first frame pushed, and increase with the -// depth of the frame. +// frameIndex: For "OnStep", "OnPop" and optionally "EnterFrame", the index of +// the topmost script frame. Indexes start at zero for the first frame pushed, +// and increase with the depth of the frame. // // An execution point is a unique identifier for a point in the recording where // the debugger can pause, and has the following properties: @@ -73,11 +73,13 @@ function pointPrecedes(pointA, pointB) { return false; } - // If an execution point doesn't have a frame index (i.e. EnterFrame) then it - // has bumped the progress counter and predates everything else that is - // associated with the same progress counter. - if ("frameIndex" in posA != "frameIndex" in posB) { - return "frameIndex" in posB; + assert("frameIndex" in posA && "frameIndex" in posB); + assert("script" in posA && "script" in posB); + + // If either point is an EnterFrame, it bumped the progress counter and + // happens first. + if (posA.kind == "EnterFrame" || posB.kind == "EnterFrame") { + return posA.kind == "EnterFrame"; } // Only certain execution point kinds do not bump the progress counter. @@ -87,7 +89,6 @@ function pointPrecedes(pointA, pointB) { // Deeper frames predate shallower frames, if the progress counter is the // same. We bump the progress counter when pushing frames, but not when // popping them. - assert("frameIndex" in posA && "frameIndex" in posB); if (posA.frameIndex != posB.frameIndex) { return posA.frameIndex > posB.frameIndex; } @@ -108,6 +109,43 @@ function pointEquals(pointA, pointB) { return !pointPrecedes(pointA, pointB) && !pointPrecedes(pointB, pointA); } +// eslint-disable-next-line no-unused-vars +function pointToString(point) { + if (point.position) { + return `${point.checkpoint}:${point.progress}:${positionToString( + point.position + )}`; + } + return `${point.checkpoint}:${point.progress}`; +} + +// eslint-disable-next-line no-unused-vars +function pointArrayIncludes(points, point) { + return points.some(p => pointEquals(point, p)); +} + +// Find the closest point in an array to point, either before or after it. +// If inclusive is set then the point itself can be returned, if it is in the +// array. +// eslint-disable-next-line no-unused-vars +function findClosestPoint(points, point, before, inclusive) { + let result = null; + for (const p of points) { + if (inclusive && pointEquals(p, point)) { + return p; + } + if (before ? pointPrecedes(p, point) : pointPrecedes(point, p)) { + if ( + !result || + (before ? pointPrecedes(result, p) : pointPrecedes(p, result)) + ) { + result = p; + } + } + } + return result; +} + // Return whether two breakpoint positions are the same. function positionEquals(posA, posB) { return ( @@ -134,19 +172,37 @@ function positionSubsumes(posA, posB) { return true; } + // EnterFrame positions may or may not specify a script. + if ( + posA.kind == "EnterFrame" && + posB.kind == "EnterFrame" && + !posA.script && + posB.script + ) { + return true; + } + return false; } +function positionToString(pos) { + return `${pos.kind}:${pos.script}:${pos.offset}:${pos.frameIndex}`; +} + function assert(v) { if (!v) { dump(`Assertion failed: ${Error().stack}\n`); - throw new Error("Assertion failed!"); + throw new Error(`Assertion failed! ${Error().stack}`); } } this.EXPORTED_SYMBOLS = [ "pointPrecedes", "pointEquals", + "pointToString", + "pointArrayIncludes", + "findClosestPoint", "positionEquals", "positionSubsumes", + "positionToString", ]; From eb7ed10883c09724ec1da29eefe9ea900d858215 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:45:35 -1000 Subject: [PATCH 6/8] Bug 1556813 Part 8 - C++ changes for instrumentation based control logic, r=loganfsmyth. --HG-- extra : rebase_source : 800cdc10e72d687674ef2d43a65674f52d6ee009 extra : histedit_source : 580801ff0f1070478c8a4fd5173b7af04560e515 --- toolkit/recordreplay/ipc/ChildIPC.cpp | 43 +- toolkit/recordreplay/ipc/ChildInternal.h | 4 + toolkit/recordreplay/ipc/ChildProcess.cpp | 2 +- toolkit/recordreplay/ipc/JSControl.cpp | 716 ++++++++++++++------ toolkit/recordreplay/ipc/ParentGraphics.cpp | 136 ++-- toolkit/recordreplay/ipc/ParentInternal.h | 34 +- 6 files changed, 663 insertions(+), 272 deletions(-) diff --git a/toolkit/recordreplay/ipc/ChildIPC.cpp b/toolkit/recordreplay/ipc/ChildIPC.cpp index 22d313b32904..023f5e3c5998 100644 --- a/toolkit/recordreplay/ipc/ChildIPC.cpp +++ b/toolkit/recordreplay/ipc/ChildIPC.cpp @@ -15,6 +15,7 @@ #include "chrome/common/mach_ipc_mac.h" #include "ipc/Channel.h" #include "mac/handler/exception_handler.h" +#include "mozilla/Base64.h" #include "mozilla/layers/ImageDataSerializer.h" #include "mozilla/Sprintf.h" #include "mozilla/VsyncDispatcher.h" @@ -29,6 +30,8 @@ #include "Thread.h" #include "Units.h" +#include "imgIEncoder.h" + #include #include #include @@ -520,15 +523,13 @@ static bool gDidRepaint; // Whether we are currently repainting. static bool gRepainting; -void Repaint(size_t* aWidth, size_t* aHeight) { +bool Repaint(nsAString& aData) { MOZ_RELEASE_ASSERT(NS_IsMainThread()); MOZ_RELEASE_ASSERT(HasDivergedFromRecording()); // Don't try to repaint if the first normal paint hasn't occurred yet. if (!gCompositorThreadId) { - *aWidth = 0; - *aHeight = 0; - return; + return false; } // Ignore the request to repaint if we already triggered a repaint, in which @@ -562,14 +563,34 @@ void Repaint(size_t* aWidth, size_t* aHeight) { gRepainting = false; } - if (gDrawTargetBuffer) { - memcpy(gGraphicsShmem, gDrawTargetBuffer, gDrawTargetBufferSize); - *aWidth = gPaintWidth; - *aHeight = gPaintHeight; - } else { - *aWidth = 0; - *aHeight = 0; + if (!gDrawTargetBuffer) { + return false; } + + // Get an image encoder for the media type. + nsCString encoderCID("@mozilla.org/image/encoder;2?type=image/png"); + nsCOMPtr encoder = do_CreateInstance(encoderCID.get()); + + nsString options; + nsresult rv = encoder->InitFromData((const uint8_t*) gDrawTargetBuffer, + gPaintWidth * gPaintHeight * 4, + gPaintWidth, + gPaintHeight, + gPaintWidth * 4, + imgIEncoder::INPUT_FORMAT_HOSTARGB, + options); + if (NS_FAILED(rv)) { + return false; + } + + uint64_t count; + rv = encoder->Available(&count); + if (NS_FAILED(rv)) { + return false; + } + + rv = Base64EncodeInputStream(encoder, aData, count); + return NS_SUCCEEDED(rv); } bool CurrentRepaintCannotFail() { diff --git a/toolkit/recordreplay/ipc/ChildInternal.h b/toolkit/recordreplay/ipc/ChildInternal.h index 62282a614b66..db4e87acb16f 100644 --- a/toolkit/recordreplay/ipc/ChildInternal.h +++ b/toolkit/recordreplay/ipc/ChildInternal.h @@ -60,6 +60,10 @@ void SendResetMiddlemanCalls(); // unhandled recording divergence per preferences. bool CurrentRepaintCannotFail(); +// Paint according to the current process state, then convert it to an image +// and serialize it in aData. +bool Repaint(nsAString& aData); + } // namespace child } // namespace recordreplay } // namespace mozilla diff --git a/toolkit/recordreplay/ipc/ChildProcess.cpp b/toolkit/recordreplay/ipc/ChildProcess.cpp index d4df5df870fa..59e2a7649603 100644 --- a/toolkit/recordreplay/ipc/ChildProcess.cpp +++ b/toolkit/recordreplay/ipc/ChildProcess.cpp @@ -69,7 +69,7 @@ void ChildProcessInfo::OnIncomingMessage(const Message& aMsg) { return; } case MessageType::Paint: - UpdateGraphicsInUIProcess(&static_cast(aMsg)); + UpdateGraphicsAfterPaint(static_cast(aMsg)); break; case MessageType::ManifestFinished: mPaused = true; diff --git a/toolkit/recordreplay/ipc/JSControl.cpp b/toolkit/recordreplay/ipc/JSControl.cpp index 8e9bb34e2c9c..81adc38ea839 100644 --- a/toolkit/recordreplay/ipc/JSControl.cpp +++ b/toolkit/recordreplay/ipc/JSControl.cpp @@ -222,26 +222,57 @@ static bool Middleman_SendManifest(JSContext* aCx, unsigned aArgc, Value* aVp) { static bool Middleman_HadRepaint(JSContext* aCx, unsigned aArgc, Value* aVp) { CallArgs args = CallArgsFromVp(aArgc, aVp); - if (!args.get(0).isNumber() || !args.get(1).isNumber()) { - JS_ReportErrorASCII(aCx, "Bad width/height"); + if (!args.get(0).isString()) { + JS_ReportErrorASCII(aCx, "Bad arguments"); return false; } - size_t width = args.get(0).toNumber(); - size_t height = args.get(1).toNumber(); + RootedString data(aCx, args.get(0).toString()); - PaintMessage message(InvalidCheckpointId, width, height); - parent::UpdateGraphicsInUIProcess(&message); + MOZ_RELEASE_ASSERT(JS_StringHasLatin1Chars(data)); + + nsCString dataBinary; + bool decodeFailed; + { + JS::AutoAssertNoGC nogc(aCx); + size_t dataLength; + const JS::Latin1Char* dataChars = + JS_GetLatin1StringCharsAndLength(aCx, nogc, data, &dataLength); + if (!dataChars) { + return false; + } + + nsDependentCString dataCString((const char*) dataChars, dataLength); + nsresult rv = Base64Decode(dataCString, dataBinary); + decodeFailed = NS_FAILED(rv); + } + + if (decodeFailed) { + JS_ReportErrorASCII(aCx, "Base64 decode failed"); + return false; + } + + parent::UpdateGraphicsAfterRepaint(dataBinary); args.rval().setUndefined(); return true; } -static bool Middleman_HadRepaintFailure(JSContext* aCx, unsigned aArgc, - Value* aVp) { +static bool Middleman_RestoreMainGraphics(JSContext* aCx, unsigned aArgc, + Value* aVp) { CallArgs args = CallArgsFromVp(aArgc, aVp); - parent::UpdateGraphicsInUIProcess(nullptr); + parent::RestoreMainGraphics(); + + args.rval().setUndefined(); + return true; +} + +static bool Middleman_ClearGraphics(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + parent::ClearGraphics(); args.rval().setUndefined(); return true; @@ -284,6 +315,28 @@ static bool Middleman_WaitUntilPaused(JSContext* aCx, unsigned aArgc, return true; } +static bool Middleman_Atomize(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + if (!args.get(0).isString()) { + JS_ReportErrorASCII(aCx, "Bad parameter"); + return false; + } + + RootedString str(aCx, args.get(0).toString()); + + // We shouldn't really be pinning the atom as well, but there isn't a JSAPI + // method for atomizing a JSString without pinning it. + JSString* atom = JS_AtomizeAndPinJSString(aCx, str); + if (!atom) { + return false; + } + + args.rval().setString(atom); + return true; +} + /////////////////////////////////////////////////////////////////////////////// // Devtools Sandbox /////////////////////////////////////////////////////////////////////////////// @@ -323,10 +376,11 @@ MOZ_EXPORT bool RecordReplayInterface_ShouldUpdateProgressCounter( // debugger. The devtools timeline is based on progress values and we don't // want gaps on the timeline which users can't seek to. if (gIncludeSystemScripts) { - // Always exclude ReplayScriptURL. Scripts in this file are internal to the - // record/replay infrastructure and run non-deterministically between - // recording and replaying. - return aURL && strcmp(aURL, ReplayScriptURL); + // Always exclude ReplayScriptURL, and any other code that it can invoke. + // Scripts in this file are internal to the record/replay infrastructure and + // run non-deterministically between recording and replaying. + return aURL && strcmp(aURL, ReplayScriptURL) && + strcmp(aURL, "resource://devtools/shared/execution-point-utils.js"); } else { return aURL && strncmp(aURL, "resource:", 9) && strncmp(aURL, "chrome:", 7); } @@ -760,184 +814,6 @@ static bool RecordReplay_SaveCheckpoint(JSContext* aCx, unsigned aArgc, return true; } -// How many hits on a script location we will precisely track for a checkpoint. -static const size_t MaxHitsPerCheckpoint = 10; - -struct ScriptHitInfo { - // Information about a location where a script offset has been hit, or an - // aggregate set of hits. - struct ScriptHit { - // The most recent checkpoint prior to the hit. - uint32_t mCheckpoint; - - // Index of the frame where the hit occurred, or UINT32_MAX if this - // represents an aggregate set of hits after the checkpoint. - uint32_t mFrameIndex; - - // Progress counter when the hit occurred, invalid if this represents an - // aggregate set of hits. - ProgressCounter mProgress; - - explicit ScriptHit(uint32_t aCheckpoint) - : mCheckpoint(aCheckpoint), mFrameIndex(UINT32_MAX), mProgress(0) {} - - ScriptHit(uint32_t aCheckpoint, uint32_t aFrameIndex, - ProgressCounter aProgress) - : mCheckpoint(aCheckpoint), - mFrameIndex(aFrameIndex), - mProgress(aProgress) {} - }; - - struct ScriptHitChunk { - ScriptHit mHits[7]; - ScriptHitChunk* mPrevious; - }; - - struct ScriptHitKey { - uint32_t mScript; - uint32_t mOffset; - - ScriptHitKey(uint32_t aScript, uint32_t aOffset) - : mScript(aScript), mOffset(aOffset) {} - - typedef ScriptHitKey Lookup; - - static HashNumber hash(const ScriptHitKey& aKey) { - return HashGeneric(aKey.mScript, aKey.mOffset); - } - - static bool match(const ScriptHitKey& aFirst, const ScriptHitKey& aSecond) { - return aFirst.mScript == aSecond.mScript && - aFirst.mOffset == aSecond.mOffset; - } - }; - - typedef HashMap> - ScriptHitMap; - ScriptHitMap mTable; - ScriptHitChunk* mFreeChunk; - - ScriptHitInfo() : mFreeChunk(nullptr) {} - - ScriptHitChunk* FindHits(uint32_t aScript, uint32_t aOffset) { - ScriptHitKey key(aScript, aOffset); - ScriptHitMap::Ptr p = mTable.lookup(key); - return p ? p->value() : nullptr; - } - - void AddHit(uint32_t aScript, uint32_t aOffset, uint32_t aCheckpoint, - uint32_t aFrameIndex, ProgressCounter aProgress) { - ScriptHitKey key(aScript, aOffset); - ScriptHitMap::AddPtr p = mTable.lookupForAdd(key); - if (!p && !mTable.add(p, key, NewChunk(nullptr))) { - MOZ_CRASH("ScriptHitInfo::AddScriptHit"); - } - - ScriptHitChunk* chunk = p->value(); - p->value() = AddHit(chunk, ScriptHit(aCheckpoint, aFrameIndex, aProgress)); - } - - ScriptHitChunk* AddHit(ScriptHitChunk* aChunk, const ScriptHit& aHit) { - for (int i = ArrayLength(aChunk->mHits) - 1; i >= 0; i--) { - if (!aChunk->mHits[i].mCheckpoint) { - aChunk->mHits[i] = aHit; - return aChunk; - } - } - ScriptHitChunk* newChunk = NewChunk(aChunk); - newChunk->mHits[ArrayLength(newChunk->mHits) - 1] = aHit; - return newChunk; - } - - ScriptHitChunk* NewChunk(ScriptHitChunk* aPrevious) { - if (!mFreeChunk) { - void* mem = AllocateMemory(PageSize, MemoryKind::ScriptHits); - ScriptHitChunk* chunks = reinterpret_cast(mem); - size_t numChunks = PageSize / sizeof(ScriptHitChunk); - for (size_t i = 0; i < numChunks - 1; i++) { - chunks[i].mPrevious = &chunks[i + 1]; - } - mFreeChunk = chunks; - } - ScriptHitChunk* result = mFreeChunk; - mFreeChunk = mFreeChunk->mPrevious; - result->mPrevious = aPrevious; - return result; - } -}; - -static ScriptHitInfo* gScriptHits; - -static void InitializeScriptHits() { - void* mem = AllocateMemory(sizeof(ScriptHitInfo), MemoryKind::ScriptHits); - gScriptHits = new (mem) ScriptHitInfo(); -} - -static bool RecordReplay_AddScriptHit(JSContext* aCx, unsigned aArgc, - Value* aVp) { - CallArgs args = CallArgsFromVp(aArgc, aVp); - - if (!args.get(0).isNumber() || !args.get(1).isNumber() || - !args.get(2).isNumber()) { - JS_ReportErrorASCII(aCx, "Bad parameters"); - return false; - } - - uint32_t script = args.get(0).toNumber(); - uint32_t offset = args.get(1).toNumber(); - uint32_t frameIndex = args.get(2).toNumber(); - - gScriptHits->AddHit(script, offset, GetLastCheckpoint(), frameIndex, - gProgressCounter); - args.rval().setUndefined(); - return true; -} - -static bool RecordReplay_FindScriptHits(JSContext* aCx, unsigned aArgc, - Value* aVp) { - CallArgs args = CallArgsFromVp(aArgc, aVp); - - if (!args.get(0).isNumber() || !args.get(1).isNumber()) { - JS_ReportErrorASCII(aCx, "Bad parameters"); - return false; - } - - uint32_t script = args.get(0).toNumber(); - uint32_t offset = args.get(1).toNumber(); - - RootedValueVector values(aCx); - - ScriptHitInfo::ScriptHitChunk* chunk = - gScriptHits ? gScriptHits->FindHits(script, offset) : nullptr; - while (chunk) { - for (const auto& hit : chunk->mHits) { - if (hit.mCheckpoint) { - RootedObject hitObject(aCx, JS_NewObject(aCx, nullptr)); - if (!hitObject || - !JS_DefineProperty(aCx, hitObject, "checkpoint", hit.mCheckpoint, - JSPROP_ENUMERATE) || - !JS_DefineProperty(aCx, hitObject, "progress", - (double)hit.mProgress, JSPROP_ENUMERATE) || - !JS_DefineProperty(aCx, hitObject, "frameIndex", hit.mFrameIndex, - JSPROP_ENUMERATE) || - !values.append(ObjectValue(*hitObject))) { - return false; - } - } - } - chunk = chunk->mPrevious; - } - - JSObject* array = JS_NewArrayObject(aCx, values); - if (!array) { - return false; - } - - args.rval().setObject(*array); - return true; -} - static bool RecordReplay_GetContent(JSContext* aCx, unsigned aArgc, Value* aVp) { CallArgs args = CallArgsFromVp(aArgc, aVp); @@ -963,23 +839,22 @@ static bool RecordReplay_GetContent(JSContext* aCx, unsigned aArgc, static bool RecordReplay_Repaint(JSContext* aCx, unsigned aArgc, Value* aVp) { CallArgs args = CallArgsFromVp(aArgc, aVp); - size_t width, height; - child::Repaint(&width, &height); + nsString data; + if (!child::Repaint(data)) { + args.rval().setNull(); + return true; + } - RootedObject obj(aCx, JS_NewObject(aCx, nullptr)); - if (!obj || - !JS_DefineProperty(aCx, obj, "width", (double)width, JSPROP_ENUMERATE) || - !JS_DefineProperty(aCx, obj, "height", (double)height, - JSPROP_ENUMERATE)) { + JSString* str = JS_NewUCStringCopyN(aCx, data.BeginReading(), data.Length()); + if (!str) { return false; } - args.rval().setObject(*obj); + args.rval().setString(str); return true; } -static bool RecordReplay_MemoryUsage(JSContext* aCx, unsigned aArgc, - Value* aVp) { +static bool RecordReplay_MemoryUsage(JSContext* aCx, unsigned aArgc, Value* aVp) { CallArgs args = CallArgsFromVp(aArgc, aVp); if (!args.get(0).isNumber()) { @@ -1011,13 +886,430 @@ static bool RecordReplay_Dump(JSContext* aCx, unsigned aArgc, Value* aVp) { if (!cstr) { return false; } - Print("%s", cstr.get()); + DirectPrint(cstr.get()); } args.rval().setUndefined(); return true; } +/////////////////////////////////////////////////////////////////////////////// +// Recording/Replaying Script Hit Methods +/////////////////////////////////////////////////////////////////////////////// + +enum ChangeFrameKind { + ChangeFrameEnter, + ChangeFrameExit, + ChangeFrameResume, + NumChangeFrameKinds +}; + +struct ScriptHitInfo { + typedef AllocPolicy AllocPolicy; + + // Information about a location where a script offset has been hit. + struct ScriptHit { + uint32_t mFrameIndex : 16; + ProgressCounter mProgress : 48; + + ScriptHit(uint32_t aFrameIndex, ProgressCounter aProgress) + : mFrameIndex(aFrameIndex), mProgress(aProgress) { + MOZ_RELEASE_ASSERT(aFrameIndex < 1 << 16); + MOZ_RELEASE_ASSERT(aProgress < uint64_t(1) << 48); + } + }; + + static_assert(sizeof(ScriptHit) == 8, "Unexpected size"); + + struct ScriptHitChunk { + ScriptHit mHits[7]; + ScriptHitChunk* mPrevious; + }; + + ScriptHitChunk* mFreeChunk; + + struct ScriptHitKey { + uint32_t mScript; + uint32_t mOffset; + + ScriptHitKey(uint32_t aScript, uint32_t aOffset) + : mScript(aScript), mOffset(aOffset) {} + + typedef ScriptHitKey Lookup; + + static HashNumber hash(const ScriptHitKey& aKey) { + return HashGeneric(aKey.mScript, aKey.mOffset); + } + + static bool match(const ScriptHitKey& aFirst, const ScriptHitKey& aSecond) { + return aFirst.mScript == aSecond.mScript + && aFirst.mOffset == aSecond.mOffset; + } + }; + + typedef HashMap + ScriptHitMap; + + struct AnyScriptHit { + uint32_t mScript; + uint32_t mFrameIndex; + ProgressCounter mProgress; + + AnyScriptHit(uint32_t aScript, uint32_t aFrameIndex, + ProgressCounter aProgress) + : mScript(aScript), mFrameIndex(aFrameIndex), mProgress(aProgress) {} + }; + + typedef InfallibleVector AnyScriptHitVector; + + struct CheckpointInfo { + ScriptHitMap mTable; + AnyScriptHitVector mChangeFrames[NumChangeFrameKinds]; + }; + + InfallibleVector mInfo; + + ScriptHitInfo() : mFreeChunk(nullptr) {} + + CheckpointInfo* GetInfo(uint32_t aCheckpoint) { + while (aCheckpoint >= mInfo.length()) { + mInfo.append(nullptr); + } + if (!mInfo[aCheckpoint]) { + void* mem = AllocateMemory(sizeof(CheckpointInfo), MemoryKind::ScriptHits); + mInfo[aCheckpoint] = new(mem) CheckpointInfo(); + } + return mInfo[aCheckpoint]; + } + + ScriptHitChunk* FindHits(uint32_t aCheckpoint, uint32_t aScript, uint32_t aOffset) { + CheckpointInfo* info = GetInfo(aCheckpoint); + + ScriptHitKey key(aScript, aOffset); + ScriptHitMap::Ptr p = info->mTable.lookup(key); + return p ? p->value() : nullptr; + } + + void AddHit(uint32_t aCheckpoint, uint32_t aScript, uint32_t aOffset, + uint32_t aFrameIndex, ProgressCounter aProgress) { + CheckpointInfo* info = GetInfo(aCheckpoint); + + ScriptHitKey key(aScript, aOffset); + ScriptHitMap::AddPtr p = info->mTable.lookupForAdd(key); + if (!p && !info->mTable.add(p, key, NewChunk(nullptr))) { + MOZ_CRASH("ScriptHitInfo::AddHit"); + } + + ScriptHitChunk* chunk = p->value(); + p->value() = AddHit(chunk, ScriptHit(aFrameIndex, aProgress)); + } + + ScriptHitChunk* AddHit(ScriptHitChunk* aChunk, const ScriptHit& aHit) { + for (int i = ArrayLength(aChunk->mHits) - 1; i >= 0; i--) { + if (!aChunk->mHits[i].mProgress) { + aChunk->mHits[i] = aHit; + return aChunk; + } + } + ScriptHitChunk* newChunk = NewChunk(aChunk); + newChunk->mHits[ArrayLength(newChunk->mHits) - 1] = aHit; + return newChunk; + } + + ScriptHitChunk* NewChunk(ScriptHitChunk* aPrevious) { + if (!mFreeChunk) { + void* mem = AllocateMemory(PageSize, MemoryKind::ScriptHits); + ScriptHitChunk* chunks = reinterpret_cast(mem); + size_t numChunks = PageSize / sizeof(ScriptHitChunk); + for (size_t i = 0; i < numChunks - 1; i++) { + chunks[i].mPrevious = &chunks[i + 1]; + } + mFreeChunk = chunks; + } + ScriptHitChunk* result = mFreeChunk; + mFreeChunk = mFreeChunk->mPrevious; + result->mPrevious = aPrevious; + return result; + } + + void AddChangeFrame(uint32_t aCheckpoint, uint32_t aWhich, + uint32_t aScript, uint32_t aFrameIndex, + ProgressCounter aProgress) { + CheckpointInfo* info = GetInfo(aCheckpoint); + MOZ_RELEASE_ASSERT(aWhich < NumChangeFrameKinds); + info->mChangeFrames[aWhich].emplaceBack(aScript, aFrameIndex, aProgress); + } + + AnyScriptHitVector* FindChangeFrames(uint32_t aCheckpoint, uint32_t aWhich) { + CheckpointInfo* info = GetInfo(aCheckpoint); + MOZ_RELEASE_ASSERT(aWhich < NumChangeFrameKinds); + return &info->mChangeFrames[aWhich]; + } +}; + +static ScriptHitInfo* gScriptHits; + +// Interned atoms for the various instrumented operations. +static JSString* gMainAtom; +static JSString* gEntryAtom; +static JSString* gBreakpointAtom; +static JSString* gExitAtom; + +static void InitializeScriptHits() { + void* mem = AllocateMemory(sizeof(ScriptHitInfo), MemoryKind::ScriptHits); + gScriptHits = new (mem) ScriptHitInfo(); + + AutoSafeJSContext cx; + JSAutoRealm ar(cx, xpc::PrivilegedJunkScope()); + + gMainAtom = JS_AtomizeAndPinString(cx, "main"); + gEntryAtom = JS_AtomizeAndPinString(cx, "entry"); + gBreakpointAtom = JS_AtomizeAndPinString(cx, "breakpoint"); + gExitAtom = JS_AtomizeAndPinString(cx, "exit"); + + MOZ_RELEASE_ASSERT(gMainAtom && gEntryAtom && gBreakpointAtom && gExitAtom); +} + +static bool gScanningScripts; +static uint32_t gFrameDepth; + +static bool RecordReplay_IsScanningScripts(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + args.rval().setBoolean(gScanningScripts); + return true; +} + +static bool RecordReplay_SetScanningScripts(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + MOZ_RELEASE_ASSERT(gFrameDepth == 0); + gScanningScripts = ToBoolean(args.get(0)); + + args.rval().setUndefined(); + return true; +} + +static bool RecordReplay_GetFrameDepth(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + args.rval().setNumber(gFrameDepth); + return true; +} + +static bool RecordReplay_SetFrameDepth(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + MOZ_RELEASE_ASSERT(gScanningScripts); + + if (!args.get(0).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameter"); + return false; + } + + gFrameDepth = args.get(0).toNumber(); + + args.rval().setUndefined(); + return true; +} + +static bool RecordReplay_OnScriptHit(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + MOZ_RELEASE_ASSERT(gScanningScripts); + + if (!args.get(1).isNumber() || !args.get(2).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + uint32_t script = args.get(1).toNumber(); + uint32_t offset = args.get(2).toNumber(); + uint32_t frameIndex = gFrameDepth - 1; + + if (!script) { + // This script is not being tracked and doesn't update the frame depth. + args.rval().setUndefined(); + return true; + } + + gScriptHits->AddHit(GetLastCheckpoint(), script, offset, + frameIndex, gProgressCounter); + args.rval().setUndefined(); + return true; +} + +template +static bool RecordReplay_OnChangeFrame(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + MOZ_RELEASE_ASSERT(gScanningScripts); + + if (!args.get(1).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + uint32_t script = args.get(1).toNumber(); + if (!script) { + // This script is not being tracked and doesn't update the frame depth. + args.rval().setUndefined(); + return true; + } + + if (Kind == ChangeFrameEnter || Kind == ChangeFrameResume) { + gFrameDepth++; + } + + uint32_t frameIndex = gFrameDepth - 1; + gScriptHits->AddChangeFrame(GetLastCheckpoint(), Kind, + script, frameIndex, gProgressCounter); + + if (Kind == ChangeFrameExit) { + gFrameDepth--; + } + + args.rval().setUndefined(); + return true; +} + +static bool RecordReplay_InstrumentationCallback(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + if (!args.get(0).isString()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + // The kind string should be an atom which we have captured already. + JSString* kind = args.get(0).toString(); + + if (kind == gBreakpointAtom) { + return RecordReplay_OnScriptHit(aCx, aArgc, aVp); + } + + if (kind == gMainAtom) { + return RecordReplay_OnChangeFrame(aCx, aArgc, aVp); + } + + if (kind == gExitAtom) { + return RecordReplay_OnChangeFrame(aCx, aArgc, aVp); + } + + if (kind == gEntryAtom) { + if (!args.get(1).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + uint32_t script = args.get(1).toNumber(); + + if (NS_FAILED(gReplay->ScriptResumeFrame(script))) { + MOZ_CRASH("RecordReplay_InstrumentationCallback"); + } + + args.rval().setUndefined(); + return true; + } + + JS_ReportErrorASCII(aCx, "Unexpected kind"); + return false; +} + +static bool RecordReplay_FindScriptHits(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + if (!args.get(0).isNumber() || + !args.get(1).isNumber() || + !args.get(2).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + uint32_t checkpoint = args.get(0).toNumber(); + uint32_t script = args.get(1).toNumber(); + uint32_t offset = args.get(2).toNumber(); + + RootedValueVector values(aCx); + + ScriptHitInfo::ScriptHitChunk* chunk = + gScriptHits ? gScriptHits->FindHits(checkpoint, script, offset) : nullptr; + while (chunk) { + for (const auto& hit : chunk->mHits) { + if (hit.mProgress) { + RootedObject hitObject(aCx, JS_NewObject(aCx, nullptr)); + if (!hitObject || + !JS_DefineProperty(aCx, hitObject, "progress", + (double) hit.mProgress, JSPROP_ENUMERATE) || + !JS_DefineProperty(aCx, hitObject, "frameIndex", + hit.mFrameIndex, JSPROP_ENUMERATE) || + !values.append(ObjectValue(*hitObject))) { + return false; + } + } + } + chunk = chunk->mPrevious; + } + + JSObject* array = JS_NewArrayObject(aCx, values); + if (!array) { + return false; + } + + args.rval().setObject(*array); + return true; +} + +static bool RecordReplay_FindChangeFrames(JSContext* aCx, unsigned aArgc, + Value* aVp) { + CallArgs args = CallArgsFromVp(aArgc, aVp); + + if (!args.get(0).isNumber() || !args.get(1).isNumber()) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + uint32_t checkpoint = args.get(0).toNumber(); + uint32_t which = args.get(1).toNumber(); + + if (which >= NumChangeFrameKinds) { + JS_ReportErrorASCII(aCx, "Bad parameters"); + return false; + } + + RootedValueVector values(aCx); + + ScriptHitInfo::AnyScriptHitVector* hits = + gScriptHits ? gScriptHits->FindChangeFrames(checkpoint, which) : nullptr; + if (hits) { + for (const ScriptHitInfo::AnyScriptHit& hit : *hits) { + RootedObject hitObject(aCx, JS_NewObject(aCx, nullptr)); + if (!hitObject || + !JS_DefineProperty(aCx, hitObject, "script", + hit.mScript, JSPROP_ENUMERATE) || + !JS_DefineProperty(aCx, hitObject, "progress", + (double) hit.mProgress, JSPROP_ENUMERATE) || + !JS_DefineProperty(aCx, hitObject, "frameIndex", + hit.mFrameIndex, JSPROP_ENUMERATE) || + !values.append(ObjectValue(*hitObject))) { + return false; + } + } + } + + JSObject* array = JS_NewArrayObject(aCx, values); + if (!array) { + return false; + } + + args.rval().setObject(*array); + return true; +} + /////////////////////////////////////////////////////////////////////////////// // Plumbing /////////////////////////////////////////////////////////////////////////////// @@ -1027,10 +1319,12 @@ static const JSFunctionSpec gMiddlemanMethods[] = { JS_FN("canRewind", Middleman_CanRewind, 0, 0), JS_FN("spawnReplayingChild", Middleman_SpawnReplayingChild, 0, 0), JS_FN("sendManifest", Middleman_SendManifest, 2, 0), - JS_FN("hadRepaint", Middleman_HadRepaint, 2, 0), - JS_FN("hadRepaintFailure", Middleman_HadRepaintFailure, 0, 0), + JS_FN("hadRepaint", Middleman_HadRepaint, 1, 0), + JS_FN("restoreMainGraphics", Middleman_RestoreMainGraphics, 0, 0), + JS_FN("clearGraphics", Middleman_ClearGraphics, 0, 0), JS_FN("inRepaintStressMode", Middleman_InRepaintStressMode, 0, 0), JS_FN("waitUntilPaused", Middleman_WaitUntilPaused, 1, 0), + JS_FN("atomize", Middleman_Atomize, 1, 0), JS_FS_END}; static const JSFunctionSpec gRecordReplayMethods[] = { @@ -1049,11 +1343,21 @@ static const JSFunctionSpec gRecordReplayMethods[] = { JS_FN("flushRecording", RecordReplay_FlushRecording, 0, 0), JS_FN("setMainChild", RecordReplay_SetMainChild, 0, 0), JS_FN("saveCheckpoint", RecordReplay_SaveCheckpoint, 1, 0), - JS_FN("addScriptHit", RecordReplay_AddScriptHit, 3, 0), - JS_FN("findScriptHits", RecordReplay_FindScriptHits, 2, 0), JS_FN("getContent", RecordReplay_GetContent, 1, 0), JS_FN("repaint", RecordReplay_Repaint, 0, 0), JS_FN("memoryUsage", RecordReplay_MemoryUsage, 0, 0), + JS_FN("isScanningScripts", RecordReplay_IsScanningScripts, 0, 0), + JS_FN("setScanningScripts", RecordReplay_SetScanningScripts, 1, 0), + JS_FN("getFrameDepth", RecordReplay_GetFrameDepth, 0, 0), + JS_FN("setFrameDepth", RecordReplay_SetFrameDepth, 1, 0), + JS_FN("onScriptHit", RecordReplay_OnScriptHit, 3, 0), + JS_FN("onEnterFrame", RecordReplay_OnChangeFrame, 2, 0), + JS_FN("onExitFrame", RecordReplay_OnChangeFrame, 2, 0), + JS_FN("onResumeFrame", RecordReplay_OnChangeFrame, 2, 0), + JS_FN("instrumentationCallback", RecordReplay_InstrumentationCallback, 3, + 0), + JS_FN("findScriptHits", RecordReplay_FindScriptHits, 3, 0), + JS_FN("findChangeFrames", RecordReplay_FindChangeFrames, 2, 0), JS_FN("dump", RecordReplay_Dump, 1, 0), JS_FS_END}; diff --git a/toolkit/recordreplay/ipc/ParentGraphics.cpp b/toolkit/recordreplay/ipc/ParentGraphics.cpp index f549196026c8..70cd7b21ed05 100644 --- a/toolkit/recordreplay/ipc/ParentGraphics.cpp +++ b/toolkit/recordreplay/ipc/ParentGraphics.cpp @@ -23,7 +23,9 @@ #include "mozilla/layers/LayerTransactionChild.h" #include "mozilla/layers/PTextureChild.h" #include "nsImportModule.h" +#include "nsStringStream.h" #include "rrIGraphics.h" +#include "ImageOps.h" #include @@ -91,47 +93,12 @@ static void InitGraphicsSandbox() { // Buffer used to transform graphics memory, if necessary. static void* gBufferMemory; -// The dimensions of the data in the graphics shmem buffer. -static size_t gLastPaintWidth, gLastPaintHeight; - -// Explicit Paint messages received from the child need to be handled with -// care to make sure we show correct graphics. Each Paint message is for the -// the process state at the most recent checkpoint in the past. When running -// (forwards or backwards) between the checkpoint and the Paint message, -// we could pause at a breakpoint and repaint the graphics at that point, -// reflecting the process state at a point later than at the checkpoint. -// In this case the Paint message's graphics will be stale. To avoid showing -// its graphics, we wait until both the Paint and the checkpoint itself have -// been hit, with no intervening repaint. - -void UpdateGraphicsInUIProcess(const PaintMessage* aMsg) { - MOZ_RELEASE_ASSERT(NS_IsMainThread()); - - if (aMsg) { - gLastPaintWidth = aMsg->mWidth; - gLastPaintHeight = aMsg->mHeight; - } - - if (!gLastPaintWidth || !gLastPaintHeight) { - return; - } - - bool hadFailure = !aMsg; - - // Make sure there is a sandbox which is running the graphics JS module. - if (!gGraphics) { - InitGraphicsSandbox(); - } - - size_t width = gLastPaintWidth; - size_t height = gLastPaintHeight; - size_t stride = - layers::ImageDataSerializer::ComputeRGBStride(gSurfaceFormat, width); - +static void UpdateMiddlemanCanvas(size_t aWidth, size_t aHeight, size_t aStride, + void* aData) { // Make sure the width and height are appropriately sized. - CheckedInt scaledWidth = CheckedInt(width) * 4; - CheckedInt scaledHeight = CheckedInt(height) * stride; - MOZ_RELEASE_ASSERT(scaledWidth.isValid() && scaledWidth.value() <= stride); + CheckedInt scaledWidth = CheckedInt(aWidth) * 4; + CheckedInt scaledHeight = CheckedInt(aHeight) * aStride; + MOZ_RELEASE_ASSERT(scaledWidth.isValid() && scaledWidth.value() <= aStride); MOZ_RELEASE_ASSERT(scaledHeight.isValid() && scaledHeight.value() <= GraphicsMemorySize); @@ -139,17 +106,17 @@ void UpdateGraphicsInUIProcess(const PaintMessage* aMsg) { // Use the shared memory buffer directly, unless we need to transform the // data due to extra memory in each row of the data which the child process // sent us. - MOZ_RELEASE_ASSERT(gGraphicsMemory); - void* memory = gGraphicsMemory; - if (stride != width * 4) { + MOZ_RELEASE_ASSERT(aData); + void* memory = aData; + if (aStride != aWidth * 4) { if (!gBufferMemory) { gBufferMemory = malloc(GraphicsMemorySize); } memory = gBufferMemory; - for (size_t y = 0; y < height; y++) { - char* src = (char*)gGraphicsMemory + y * stride; - char* dst = (char*)gBufferMemory + y * width * 4; - memcpy(dst, src, width * 4); + for (size_t y = 0; y < aHeight; y++) { + char* src = (char*)aData + y * aStride; + char* dst = (char*)gBufferMemory + y * aWidth * 4; + memcpy(dst, src, aWidth * 4); } } @@ -159,14 +126,14 @@ void UpdateGraphicsInUIProcess(const PaintMessage* aMsg) { // Create an ArrayBuffer whose contents are the externally-provided |memory|. JS::Rooted bufferObject(cx); bufferObject = - JS::NewArrayBufferWithUserOwnedContents(cx, width * height * 4, memory); + JS::NewArrayBufferWithUserOwnedContents(cx, aWidth * aHeight * 4, memory); MOZ_RELEASE_ASSERT(bufferObject); JS::Rooted buffer(cx, JS::ObjectValue(*bufferObject)); // Call into the graphics module to update the canvas it manages. - if (NS_FAILED(gGraphics->UpdateCanvas(buffer, width, height, hadFailure))) { - MOZ_CRASH("UpdateGraphicsInUIProcess"); + if (NS_FAILED(gGraphics->UpdateCanvas(buffer, aWidth, aHeight))) { + MOZ_CRASH("UpdateMiddlemanCanvas"); } // Manually detach this ArrayBuffer once this update completes, as the @@ -175,6 +142,75 @@ void UpdateGraphicsInUIProcess(const PaintMessage* aMsg) { MOZ_ALWAYS_TRUE(JS::DetachArrayBuffer(cx, bufferObject)); } +// The dimensions of the data in the graphics shmem buffer. +static size_t gLastPaintWidth, gLastPaintHeight; + +void UpdateGraphicsAfterPaint(const PaintMessage& aMsg) { + MOZ_RELEASE_ASSERT(NS_IsMainThread()); + + gLastPaintWidth = aMsg.mWidth; + gLastPaintHeight = aMsg.mHeight; + + if (!aMsg.mWidth || !aMsg.mHeight) { + return; + } + + // Make sure there is a sandbox which is running the graphics JS module. + if (!gGraphics) { + InitGraphicsSandbox(); + } + + size_t stride = + layers::ImageDataSerializer::ComputeRGBStride(gSurfaceFormat, + aMsg.mWidth); + UpdateMiddlemanCanvas(aMsg.mWidth, aMsg.mHeight, stride, gGraphicsMemory); +} + +void UpdateGraphicsAfterRepaint(const nsACString& aImageData) { + nsCOMPtr stream; + nsresult rv = NS_NewCStringInputStream(getter_AddRefs(stream), aImageData); + MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv)); + + RefPtr surface = + image::ImageOps::DecodeToSurface(stream.forget(), + NS_LITERAL_CSTRING("image/png"), 0); + MOZ_RELEASE_ASSERT(surface); + + RefPtr dataSurface = surface->GetDataSurface(); + + gfx::DataSourceSurface::ScopedMap map(dataSurface, + gfx::DataSourceSurface::READ); + + UpdateMiddlemanCanvas(surface->GetSize().width, + surface->GetSize().height, + map.GetStride(), map.GetData()); +} + +void RestoreMainGraphics() { + if (!gLastPaintWidth || !gLastPaintHeight) { + return; + } + + size_t stride = + layers::ImageDataSerializer::ComputeRGBStride(gSurfaceFormat, + gLastPaintWidth); + UpdateMiddlemanCanvas(gLastPaintWidth, gLastPaintHeight, stride, + gGraphicsMemory); +} + +void ClearGraphics() { + if (!gGraphics) { + return; + } + + AutoSafeJSContext cx; + JSAutoRealm ar(cx, xpc::PrivilegedJunkScope()); + + if (NS_FAILED(gGraphics->ClearCanvas())) { + MOZ_CRASH("ClearGraphics"); + } +} + bool InRepaintStressMode() { static bool checked = false; static bool rv; diff --git a/toolkit/recordreplay/ipc/ParentInternal.h b/toolkit/recordreplay/ipc/ParentInternal.h index c4b7c2f2baac..a022399db150 100644 --- a/toolkit/recordreplay/ipc/ParentInternal.h +++ b/toolkit/recordreplay/ipc/ParentInternal.h @@ -71,15 +71,41 @@ static Monitor* gMonitor; // Graphics /////////////////////////////////////////////////////////////////////////////// +// Painting can happen in two ways: +// +// - When the main child runs (the recording child, or a dedicated replaying +// child if there is no recording child), it does so on the user's machine and +// paints into gGraphicsMemory, a buffer shared with the middleman process. +// After the buffer has been updated, a PaintMessage is sent to the middleman. +// +// - When the user is within the recording and we want to repaint old graphics, +// gGraphicsMemory is not updated (the replaying process could be on a distant +// machine and be unable to access the buffer). Instead, the replaying process +// does its repaint locally, losslessly compresses it to a PNG image, encodes +// it to base64, and sends it to the middleman. The middleman then undoes this +// encoding and paints the resulting image. +// +// In either case, a canvas in the middleman is filled with the paint data, +// updating the graphics shown by the UI process. The canvas is managed by +// devtools/server/actors/replay/graphics.js extern void* gGraphicsMemory; void InitializeGraphicsMemory(); void SendGraphicsMemoryToChild(); -// Update the graphics painted in the UI process, per painting data received -// from a child process, or null if a repaint was triggered and failed due to -// an unhandled recording divergence. -void UpdateGraphicsInUIProcess(const PaintMessage* aMsg); +// Update the graphics painted in the UI process after a paint happened in the +// main child. +void UpdateGraphicsAfterPaint(const PaintMessage& aMsg); + +// Update the graphics painted in the UI process after a repaint happened in +// some replaying child. +void UpdateGraphicsAfterRepaint(const nsACString& imageData); + +// Restore the graphics last painted by the main child. +void RestoreMainGraphics(); + +// Clear any graphics painted in the UI process. +void ClearGraphics(); // ID for the mach message sent from a child process to the middleman to // request a port for the graphics shmem. From af8698c62b329ef2997e0acdd0168f6245db09f4 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:46:14 -1000 Subject: [PATCH 7/8] Bug 1556813 Part 9 - Show unscanned regions and uncached points on timeline, r=loganfsmyth. --HG-- extra : rebase_source : 0015ede8437c6e0cccc9d8a349d744358575e320 extra : histedit_source : 364647d06744a93f98082a656dd6782d341d611a --- devtools/client/themes/toolbox.css | 11 ++++ .../webreplay/components/WebReplayPlayer.js | 62 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/devtools/client/themes/toolbox.css b/devtools/client/themes/toolbox.css index 1072642b7a39..ab4d523d616b 100644 --- a/devtools/client/themes/toolbox.css +++ b/devtools/client/themes/toolbox.css @@ -503,6 +503,10 @@ transition-duration: 100ms; } +.webreplay-player .message.uncached { + opacity: 0.5; +} + .webreplay-player .recording .message.highlighted { background-color: var(--recording-marker-background-hover); } @@ -648,3 +652,10 @@ .webreplay-player #overlay .tick:hover ~ .tick { opacity: 0.5; } + +.webreplay-player .unscanned { + position: absolute; + height: 100%; + background: #000000; + opacity: 0.1; +} diff --git a/devtools/client/webreplay/components/WebReplayPlayer.js b/devtools/client/webreplay/components/WebReplayPlayer.js index fd744496cb79..517c1b9aefc6 100644 --- a/devtools/client/webreplay/components/WebReplayPlayer.js +++ b/devtools/client/webreplay/components/WebReplayPlayer.js @@ -107,6 +107,8 @@ class WebReplayPlayer extends Component { paused: false, messages: [], highlightedMessage: null, + unscannedRegions: [], + cachedPoints: [], start: 0, end: 1, }; @@ -200,7 +202,12 @@ class WebReplayPlayer extends Component { } onProgress(packet) { - const { recording, executionPoint } = packet; + const { + recording, + executionPoint, + unscannedRegions, + cachedPoints, + } = packet; log(`progress: ${recording ? "rec" : "play"} ${executionPoint.progress}`); if (this.state.seeking) { @@ -212,7 +219,12 @@ class WebReplayPlayer extends Component { return; } - const newState = { recording, executionPoint }; + const newState = { + recording, + executionPoint, + unscannedRegions, + cachedPoints, + }; if (recording) { newState.recordingEndpoint = executionPoint; } @@ -453,7 +465,12 @@ class WebReplayPlayer extends Component { } renderMessage(message, index) { - const { messages, executionPoint, highlightedMessage } = this.state; + const { + messages, + executionPoint, + highlightedMessage, + cachedPoints, + } = this.state; const offset = this.getVisibleOffset(message.executionPoint); const previousMessage = messages[index - 1]; @@ -477,11 +494,16 @@ class WebReplayPlayer extends Component { const isHighlighted = highlightedMessage == message.id; + const uncached = + message.executionPoint && + !cachedPoints.includes(message.executionPoint.progress); + return dom.a({ className: classname("message", { overlayed: isOverlayed, future: isFuture, highlighted: isHighlighted, + uncached, }), style: { left: `${Math.max(offset - markerWidth / 2, 0)}px`, @@ -525,6 +547,37 @@ class WebReplayPlayer extends Component { }); } + renderUnscannedRegions() { + return this.state.unscannedRegions.map( + this.renderUnscannedRegion.bind(this) + ); + } + + renderUnscannedRegion({ start, end }) { + let startOffset = this.getVisibleOffset({ progress: start }); + let endOffset = this.getVisibleOffset({ progress: end }); + + if (startOffset > this.overlayWidth || endOffset < 0) { + return null; + } + + if (startOffset < 0) { + startOffset = 0; + } + + if (endOffset > this.overlayWidth) { + endOffset = this.overlayWidth; + } + + return dom.span({ + className: classname("unscanned"), + style: { + left: `${startOffset}px`, + width: `${endOffset - startOffset}px`, + }, + }); + } + render() { const percent = this.getVisiblePercent(this.state.executionPoint); const recording = this.isRecording(); @@ -560,7 +613,8 @@ class WebReplayPlayer extends Component { style: { left: `${percent}%`, width: `${100 - percent}%` }, }), ...this.renderMessages(), - ...this.renderTicks() + ...this.renderTicks(), + ...this.renderUnscannedRegions() ) ) ) From 8e2cb113e03e4a2e004bfa06765c0edab1b6c510 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Jun 2019 07:46:57 -1000 Subject: [PATCH 8/8] Bug 1556813 Part 10 - Add test for breakpoint hits with complex control flow, r=loganfsmyth. MANUAL PUSH: Lando gives me strange errors and no one is around in #lando to help diagnose the problem. --HG-- extra : rebase_source : 3701625298b2647d8cef671d75fdda4340fb43b1 extra : histedit_source : c2ea99a43942a6dcf7bd5b527b87387b03d38151 --- .../client/webreplay/mochitest/browser.ini | 2 + .../browser_dbg_rr_breakpoints-06.js | 49 +++++++++ .../mochitest/examples/doc_control_flow.html | 104 ++++++++++++++++++ devtools/client/webreplay/mochitest/head.js | 5 + 4 files changed, 160 insertions(+) create mode 100644 devtools/client/webreplay/mochitest/browser_dbg_rr_breakpoints-06.js create mode 100644 devtools/client/webreplay/mochitest/examples/doc_control_flow.html diff --git a/devtools/client/webreplay/mochitest/browser.ini b/devtools/client/webreplay/mochitest/browser.ini index 0c9c20abcea3..1101502c5df8 100644 --- a/devtools/client/webreplay/mochitest/browser.ini +++ b/devtools/client/webreplay/mochitest/browser.ini @@ -17,6 +17,7 @@ support-files = examples/doc_rr_continuous.html examples/doc_rr_logs.html examples/doc_rr_error.html + examples/doc_control_flow.html examples/doc_inspector_basic.html examples/doc_inspector_styles.html examples/styles.css @@ -26,6 +27,7 @@ support-files = [browser_dbg_rr_breakpoints-03.js] [browser_dbg_rr_breakpoints-04.js] [browser_dbg_rr_breakpoints-05.js] +[browser_dbg_rr_breakpoints-06.js] [browser_dbg_rr_record.js] [browser_dbg_rr_stepping-01.js] [browser_dbg_rr_stepping-02.js] diff --git a/devtools/client/webreplay/mochitest/browser_dbg_rr_breakpoints-06.js b/devtools/client/webreplay/mochitest/browser_dbg_rr_breakpoints-06.js new file mode 100644 index 000000000000..4d00762aa45c --- /dev/null +++ b/devtools/client/webreplay/mochitest/browser_dbg_rr_breakpoints-06.js @@ -0,0 +1,49 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable no-undef */ + +"use strict"; + +// Test hitting breakpoints when using tricky control flow constructs: +// catch, finally, generators, and async/await. +add_task(async function() { + const dbg = await attachRecordingDebugger("doc_control_flow.html", { + waitForRecording: true, + }); + const { threadFront, tab, toolbox } = dbg; + + const breakpoints = []; + + await rewindToBreakpoint(10); + await resumeToBreakpoint(12); + await resumeToBreakpoint(18); + await resumeToBreakpoint(20); + await resumeToBreakpoint(32); + await resumeToBreakpoint(27); + await resumeToLine(threadFront, 32); + await resumeToLine(threadFront, 27); + await resumeToBreakpoint(42); + await resumeToBreakpoint(44); + await resumeToBreakpoint(50); + await resumeToBreakpoint(54); + + for (const bp of breakpoints) { + await threadFront.removeBreakpoint(bp); + } + await toolbox.closeToolbox(); + await gBrowser.removeTab(tab); + + async function rewindToBreakpoint(line) { + const bp = await setBreakpoint(threadFront, "doc_control_flow.html", line); + breakpoints.push(bp); + await rewindToLine(threadFront, line); + } + + async function resumeToBreakpoint(line) { + const bp = await setBreakpoint(threadFront, "doc_control_flow.html", line); + breakpoints.push(bp); + await resumeToLine(threadFront, line); + } +}); diff --git a/devtools/client/webreplay/mochitest/examples/doc_control_flow.html b/devtools/client/webreplay/mochitest/examples/doc_control_flow.html new file mode 100644 index 000000000000..a6838f3c8951 --- /dev/null +++ b/devtools/client/webreplay/mochitest/examples/doc_control_flow.html @@ -0,0 +1,104 @@ + + +
Hello World!
+ + + diff --git a/devtools/client/webreplay/mochitest/head.js b/devtools/client/webreplay/mochitest/head.js index 2ff420605a73..b5373240b68d 100644 --- a/devtools/client/webreplay/mochitest/head.js +++ b/devtools/client/webreplay/mochitest/head.js @@ -166,3 +166,8 @@ PromiseTestUtils.whitelistRejectionsGlobally(/NS_ERROR_NOT_INITIALIZED/); PromiseTestUtils.whitelistRejectionsGlobally( /Current thread has paused or resumed/ ); + +// When running the full test suite, long delays can occur early on in tests, +// before child processes have even been spawned. Allow a longer timeout to +// avoid failures from this. +requestLongerTimeout(120);