Files
servo/components/default-resources/resources/debugger.js
Jonathan Schwender f4877c190e Embed default resources in Servo applications using a servo-default-resources crate (#43182)
This PR considers the following constraints:

- Resources must be available when building servo via a published
crates.io package (i.e. no `../../../resources/<file>` file references).
- Minimal setup when writing tests (`nextest` spawns each test in its
own process, so we don't want to explicitly initialize the resource
handler for every `#[test]` fn)
- Use local resources when developing locally
- Support loading the resources from a proper resource directory if the
embedder wishes so, including via a custom mechanism, not necessarily as
files

(File) Resources that are only accessed from servoshell are out of scope
of this PR, since it mainly focusses on unblocking publishing `libservo`
to crates.io.

Baking the resources into the binary by default simplifies the setup a
lot. We already supported that before, but only for testing purposes and
explicitly not for production builds.

Using [`inventory`](https://crates.io/crates/inventory) adds a simple
way for the embedder to replace the default baked in resources, while
also keeping the test usage of baked in resources simple.

rippy.png is also referenced from image_cache - We simply duplicate it,
since the image is small, to avoid adding unnecessarily complex
solutions like adding a dedicated crate.


Testing: Covered by existing tests. [mach try
full](https://github.com/jschwe/servo/actions/runs/23811669469)
Fixes: Part of #43145

---------

Signed-off-by: Jonathan Schwender <schwenderjonathan@gmail.com>
2026-04-01 07:16:28 +00:00

564 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
if ("dbg" in this) {
throw new Error("Debugger script must not run more than once!");
}
const dbg = new Debugger;
const debuggeesToPipelineIds = new Map;
const debuggeesToWorkerIds = new Map;
const sourceIdsToScripts = new Map;
const frameActorsToFrames = new Map;
const environmentActorsToEnvironments = new Map;
// <https://searchfox.org/firefox-main/source/devtools/server/actors/thread.js#155>
// Possible values for the `why.type` attribute in "paused" event
const PAUSE_REASONS = {
INTERRUPTED: "interrupted", // Associated with why.onNext attribute
RESUME_LIMIT: "resumeLimit",
};
// Find script by scriptId within a script tree
function findScriptById(script, scriptId) {
if (script.sourceStart === scriptId) {
return script;
}
for (const child of script.getChildScripts()) {
const found = findScriptById(child, scriptId);
if (found) return found;
}
return null;
}
// Walk script tree and call callback for each script
function walkScriptTree(script, callback) {
callback(script);
for (const child of script.getChildScripts()) {
walkScriptTree(child, callback);
}
}
// Find a key by a value in a map
function findKeyByValue(map, search) {
for (const [key, value] of map) {
if (value === search) return key;
}
return undefined;
}
dbg.uncaughtExceptionHook = function(error) {
console.error(`[debugger] Uncaught exception at ${error.fileName}:${error.lineNumber}:${error.columnNumber}: ${error.name}: ${error.message}`);
};
dbg.onNewScript = function(script) {
// TODO: handle wasm (`script.source.introductionType == wasm`)
sourceIdsToScripts.set(script.source.id, script);
notifyNewSource({
pipelineId: debuggeesToPipelineIds.get(script.global),
workerId: debuggeesToWorkerIds.get(script.global),
spidermonkeyId: script.source.id,
url: script.source.url,
urlOverride: script.source.displayURL,
text: script.source.text,
introductionType: script.source.introductionType ?? null,
});
};
// Track a new debuggee global
addEventListener("addDebuggee", event => {
const {global, pipelineId, workerId} = event;
const debuggerObject = dbg.addDebuggee(global);
debuggeesToPipelineIds.set(debuggerObject, pipelineId);
if (workerId !== undefined) {
debuggeesToWorkerIds.set(debuggerObject, workerId);
}
});
// Maximum number of properties to include in preview
// <https://searchfox.org/firefox-main/source/devtools/server/actors/object/previewers.js#29>
const OBJECT_PREVIEW_MAX_ITEMS = 10;
// <https://searchfox.org/mozilla-central/source/devtools/server/actors/object/previewers.js#80>
const previewers = {
Function: [],
Array: [],
Object: [],
// TODO: Add Map, FormData etc
};
// Convert debuggee value to property descriptor value
// <https://searchfox.org/firefox-main/source/devtools/server/actors/object/utils.js#116>
function createValueGrip(value) {
switch (typeof value) {
case "undefined":
return { valueType: "undefined" };
case "boolean":
return { valueType: "boolean", booleanValue: value };
case "number":
return { valueType: "number", numberValue: value };
case "string":
return { valueType: "string", stringValue: value };
case "object":
if (value === null) {
return { valueType: "null" };
}
// Debugger.Object - get preview using registered previewers
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Object.html>
return {
valueType: "object",
objectClass: value.class,
preview: getPreview(value),
};
default:
return { valueType: "string", stringValue: String(value) };
}
}
// Extract own properties from a debuggee object
// <https://firefox-source-docs.mozilla.org/devtools-user/debugger-api/debugger.object/index.html#function-properties-of-the-debugger-object-prototype>
function extractOwnProperties(obj, maxItems = OBJECT_PREVIEW_MAX_ITEMS) {
const ownProperties = [];
let totalLength = 0;
let names;
try {
names = obj.getOwnPropertyNames();
totalLength = names.length;
} catch (e) {
return { ownProperties, ownPropertiesLength: 0 };
}
let count = 0;
for (const name of names) {
if (count >= maxItems) break;
try {
const desc = obj.getOwnPropertyDescriptor(name);
if (desc) {
const prop = {
name: name,
configurable: desc.configurable ?? false,
enumerable: desc.enumerable ?? false,
writable: desc.writable ?? false,
isAccessor: desc.get !== undefined || desc.set !== undefined,
value: createValueGrip(undefined),
};
if (desc.value !== undefined) {
prop.value = createValueGrip(desc.value);
} else if (desc.get) {
try {
const result = desc.get.call(obj);
if (result && "return" in result) {
prop.value = createValueGrip(result.return);
}
} catch (e) { }
}
ownProperties.push(prop);
count++;
}
} catch (e) {
// For now skip properties that throw on access
}
}
return { ownProperties, ownPropertiesLength: totalLength };
}
// <https://searchfox.org/mozilla-central/source/devtools/server/actors/object/previewers.js#125>
previewers.Function.push(function FunctionPreviewer(obj) {
const { ownProperties, ownPropertiesLength } = extractOwnProperties(obj);
return {
kind: "Object",
ownProperties,
ownPropertiesLength,
function: {
name: obj.name,
displayName: obj.displayName,
parameterNames: obj.parameterNames,
isAsync: obj.isAsyncFunction,
isGenerator: obj.isGeneratorFunction,
}
};
});
// <https://searchfox.org/mozilla-central/source/devtools/server/actors/object/previewers.js#172>
// TODO: Add implementation for showing Array items
previewers.Array.push(function ArrayPreviewer(obj) {
const lengthDescriptor = obj.getOwnPropertyDescriptor("length");
const length = lengthDescriptor ? lengthDescriptor.value : 0;
return {
kind: "ArrayLike",
arrayLength: length,
};
});
// Generic fallback for object previewer
// <https://searchfox.org/mozilla-central/source/devtools/server/actors/object/previewers.js#856>
previewers.Object.push(function ObjectPreviewer(obj) {
const { ownProperties, ownPropertiesLength } = extractOwnProperties(obj);
return {
kind: "Object",
ownProperties,
ownPropertiesLength,
};
});
function getPreview(obj) {
const className = obj.class;
// <https://searchfox.org/mozilla-central/source/devtools/server/actors/object.js#295>
const typePreviewers = previewers[className] || previewers.Object;
for (const previewer of typePreviewers) {
const result = previewer(obj);
if (result) return result;
}
return { ownProperties: [], ownPropertiesLength: 0 };
}
// Evaluate some javascript code in the global context of the debuggee
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Object.html#executeinglobal-code-options>
addEventListener("eval", event => {
const {code, pipelineId, workerId, frameActorId} = event;
let completionValue;
if (frameActorId) {
const frame = frameActorsToFrames.get(frameActorId);
// <https://searchfox.org/firefox-main/source/js/src/doc/Debugger/Debugger.Frame.md#223>
if (frame?.onStack) {
completionValue = frame.eval(code);
} else {
completionValue = { throw: "Frame not available" };
}
} else {
const object = workerId !== undefined ?
findKeyByValue(debuggeesToWorkerIds, workerId) :
findKeyByValue(debuggeesToPipelineIds, pipelineId);
completionValue = object.executeInGlobal(code);
}
// Completion values: <https://firefox-source-docs.mozilla.org/js/Debugger/Conventions.html#completion-values>
let resultValue;
if (completionValue === null) {
resultValue = { completionType: "terminated", value: createValueGrip(undefined), hasException: false };
} else if ("throw" in completionValue) {
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.html#adoptdebuggeevalue-value>
// <https://searchfox.org/firefox-main/source/devtools/server/actors/webconsole/eval-with-debugger.js#312>
// we probably don't need adoptDebuggeeValue, as we only have one debugger instance for now
// let value = dbg.adoptDebuggeeValue(completionValue.throw);
resultValue = { completionType: "throw", value: createValueGrip(completionValue.throw), hasException: true };
} else if ("return" in completionValue) {
resultValue = { completionType: "return", value: createValueGrip(completionValue.return), hasException: false };
}
// To avoid recursion errors in the WebIDL, preview needs to live outside of the property descriptor
if (resultValue.value.preview) {
resultValue.preview = resultValue.value.preview;
delete resultValue.value.preview;
}
evalResult(event, resultValue);
});
addEventListener("getPossibleBreakpoints", event => {
const {spidermonkeyId} = event;
const script = sourceIdsToScripts.get(spidermonkeyId);
const result = [];
walkScriptTree(script, (currentScript) => {
for (const location of currentScript.getPossibleBreakpoints()) {
location["scriptId"] = currentScript.sourceStart;
result.push(location);
}
});
getPossibleBreakpointsResult(event, result);
});
function createFrameActor(frame, pipelineId) {
let frameActorId = findKeyByValue(frameActorsToFrames, frame);
if (!frameActorId) {
// TODO: Check if we already have an actor for this frame
frameActorId = registerFrameActor(pipelineId, {
// TODO: Some properties throw if terminated is true
// TODO: arguments: frame.arguments,
displayName: frame.script.displayName,
onStack: frame.onStack,
oldest: frame.older == null,
terminated: frame.terminated,
type_: frame.type,
url: frame.script.url,
});
if (!frameActorId) {
console.error("[debugger] Couldn't create frame");
return undefined;
}
frameActorsToFrames.set(frameActorId, frame);
}
return frameActorId;
}
function handlePauseAndRespond(frame, pauseReason) {
dbg.onEnterFrame = undefined;
clearSteppingHooks(frame);
// Get the pipeline ID for this debuggee
const pipelineId = debuggeesToPipelineIds.get(frame.script.global);
if (!pipelineId) {
console.error("[debugger] No pipeline ID for frame's global");
return undefined;
}
let frameActorId = createFrameActor(frame, pipelineId);
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Script.html#getoffsetmetadata-offset>
const offset = frame.offset;
const offsetMetadata = frame.script.getOffsetMetadata(offset);
const frameOffset = {
frameActorId,
column: offsetMetadata.columnNumber - 1,
line: offsetMetadata.lineNumber
};
// Notify devtools and enter pause loop. This blocks until Resume.
pauseAndRespond(
pipelineId,
frameOffset,
pauseReason
);
// <https://firefox-source-docs.mozilla.org/js/Debugger/Conventions.html#resumption-values>
// Return undefined to continue execution normally after resume.
return undefined;
}
addEventListener("frames", event => {
const {pipelineId, start, count} = event;
let frameList = handleListFrames(pipelineId, start, count);
listFramesResult(frameList);
})
// <https://searchfox.org/firefox-main/source/devtools/server/actors/thread.js#1425>
function handleListFrames(pipelineId, start, count) {
let frame = dbg.getNewestFrame()
const walkToParentFrame = () => {
if (!frame) {
return;
}
const currentFrame = frame;
frame = null;
if (currentFrame.older) {
frame = currentFrame.older;
}
}
let i = 0;
while (frame && i < start) {
walkToParentFrame();
i++;
}
// Return count frames, or all remaining frames if count is not defined.
const frames = [];
for (; frame && (!count || i < start + count); i++, walkToParentFrame()) {
const frameActorId = createFrameActor(frame, pipelineId);
frames.push(frameActorId);
}
return frames;
}
addEventListener("setBreakpoint", event => {
const {spidermonkeyId, scriptId, offset} = event;
const script = sourceIdsToScripts.get(spidermonkeyId);
const target = findScriptById(script, scriptId);
if (target) {
target.setBreakpoint(offset, {
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Script.html#setbreakpoint-offset-handler>
// The hit handler receives a Debugger.Frame instance representing the currently executing stack frame.
hit: (frame) => handlePauseAndRespond(frame, {type_: "breakpoint"})
});
}
});
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Frame.html>
addEventListener("interrupt", event => {
dbg.onEnterFrame = (frame) => handlePauseAndRespond(
frame,
{ type_: PAUSE_REASONS.INTERRUPTED, onNext: true }
);
});
function makeSteppingHooks(steppingType, startFrame) {
return {
onEnterFrame: (frame) => {
const { onStep, onPop } = makeSteppingHooks("next", frame);
frame.onStep = onStep;
frame.onPop = onPop;
},
onStep: () => {
const meta = startFrame.script.getOffsetMetadata(startFrame.offset);
if (meta.isBreakpoint && meta.isStepStart) {
return handlePauseAndRespond(startFrame, { type_: PAUSE_REASONS.RESUME_LIMIT });
}
},
onPop: (completion) => {
this.reportedPop = true;
suspendedFrame = startFrame;
if (steppingType !== "finish") {
return handlePauseAndRespond(startFrame, completion);
}
attachSteppingHooks("next", startFrame);
},
}
}
function getNextStepFrame(frame) {
const endOfFrame = frame.reportedPop;
const stepFrame = endOfFrame ? frame.older : frame;
if (!stepFrame || !stepFrame.script) {
return null;
}
return stepFrame;
}
// <https://searchfox.org/firefox-main/source/devtools/server/actors/thread.js#1235>
function attachSteppingHooks(steppingType, frame) {
if (steppingType === "finish" && frame.reportedPop) {
steppingType = "next";
}
const stepFrame = getNextStepFrame(frame);
if (!stepFrame) {
steppingType = "step";
}
const { onEnterFrame, onStep, onPop } = makeSteppingHooks(
steppingType,
frame,
);
if (steppingType === "step") {
dbg.onEnterFrame = onEnterFrame;
}
if (stepFrame) {
switch (steppingType) {
case "step":
case "next":
if (stepFrame.script) {
stepFrame.onStep = onStep;
}
case "finish":
stepFrame.onPop = onPop;
break;
}
}
}
function clearSteppingHooks(suspendedFrame) {
if (suspendedFrame) {
suspendedFrame.onStep = undefined;
suspendedFrame.onPop = undefined;
}
let frame = this.youngestFrame;
if (frame?.onStack) {
while (frame) {
frame.onStep = undefined;
frame.onPop = undefined;
frame = frame.older;
}
}
}
// <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#resuming-a-thread>
addEventListener("resume", event => {
const {resumeLimitType: steppingType, frameActorID} = event;
let frame = dbg.getNewestFrame();
if (frameActorID) {
frame = frameActorsToFrames.get(frameActorID);
if (!frame) {
console.error("[debugger] Couldn't find frame");
}
}
if (steppingType) {
attachSteppingHooks(steppingType, frame);
} else {
clearSteppingHooks(frame);
}
});
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Script.html#clearbreakpoint-handler-offset>
// There may be more than one breakpoint at the same offset with different handlers, but we dont handle that case for now.
addEventListener("clearBreakpoint", event => {
const {spidermonkeyId, scriptId, offset} = event;
const script = sourceIdsToScripts.get(spidermonkeyId);
const target = findScriptById(script, scriptId);
if (target) {
// <https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.Script.html#clearallbreakpoints-offset>
// If the instance refers to a JSScript, remove all breakpoints set in this script at that offset.
target.clearAllBreakpoints(offset);
}
});
// TODO: Get variables (scopes don't show if they don't have a variable)
function createEnvironmentActor(environment) {
let actor = findKeyByValue(environmentActorsToEnvironments, environment);
if (!actor) {
let info = {};
if (environment.type == "declarative") {
info.type_ = environment.calleeScript ? "function" : "block";
} else {
info.type_ = environment.type;
}
info.scopeKind = environment.scopeKind;
if (environment.calleeScript) {
info.functionDisplayName = environment.calleeScript.displayName;
}
let parent = null;
if (environment.parent) {
parent = createEnvironmentActor(environment.parent);
}
if (environment.type == "declarative") {
info.bindingVariables = buildBindings(environment)
}
// TODO: Update this instead of registering
actor = registerEnvironmentActor(info, parent);
environmentActorsToEnvironments.set(actor, environment);
}
return actor;
}
function buildBindings(environment) {
let bindingVar = new Map();
for (const name of environment.names()) {
const value = environment.getVariable(name);
// <https://searchfox.org/firefox-main/source/devtools/server/actors/environment.js#87>
// We should not do this, it is more of a place holder for now.
// TODO: build and pass correct structure for this. This structure is very similar to "eval"
bindingVar[name] = JSON.stringify(value);
}
return bindingVar;
}
// Get a `Debugger.Environment` instance within which evaluation is taking place.
// <https://searchfox.org/firefox-main/source/devtools/server/actors/frame.js#109>
addEventListener("getEnvironment", event => {
const {frameActorId} = event;
frame = frameActorsToFrames.get(frameActorId);
const actor = createEnvironmentActor(frame.environment);
getEnvironmentResult(actor);
});