ext_debugger
The ext_debugger crate provides comprehensive debugging capabilities for Forge applications through the runtime:debugger module, implementing a complete Chrome DevTools Protocol (CDP) client for V8 runtime introspection.
Overview
ext_debugger enables full programmatic control over JavaScript/TypeScript execution via the V8 Inspector Protocol. It provides WebSocket-based communication with V8’s debugging infrastructure, allowing you to set breakpoints, step through code, inspect variables, evaluate expressions, and monitor script loading - all from TypeScript.
Key Capabilities
- Breakpoint Management: Set, remove, enable/disable breakpoints with optional conditions
- Execution Control: Pause, resume, step over/into/out of functions
- Stack Inspection: Access call frames, scope chains, and variable values
- Object Inspection: Fetch properties of complex runtime objects
- Expression Evaluation: Execute arbitrary JavaScript in global or frame context
- Script Management: List loaded scripts and retrieve source code
- Event Handling: React to pause and script loading events
- Exception Debugging: Configure pause-on-exception behavior
Module: runtime:debugger
import { // Connection connect, disconnect, isConnected,
// Breakpoints setBreakpoint, removeBreakpoint, removeAllBreakpoints, listBreakpoints, enableBreakpoint, disableBreakpoint,
// Execution Control pause, resume, stepOver, stepInto, stepOut, continueToLocation, setPauseOnExceptions,
// Inspection getCallFrames, getScopeChain, getProperties, evaluate, setVariableValue,
// Scripts getScriptSource, listScripts,
// Events onPaused, onScriptParsed} from "runtime:debugger";Quick Start
Basic Debugging Session
import * as debugger from "runtime:debugger";
// Connect to V8 Inspectorawait debugger.connect();
// Set a breakpointconst bp = await debugger.setBreakpoint("file:///src/main.ts", 42);console.log(`Breakpoint set: ${bp.id}`);
// Listen for pause eventsconst cleanup = debugger.onPaused(async (event) => { console.log(`Paused: ${event.reason}`);
// Print stack trace for (const frame of event.call_frames) { console.log(` at ${frame.function_name} (${frame.url}:${frame.location.line_number})`); }
// Resume execution await debugger.resume();});
// Cleanup when donecleanup();await debugger.disconnect();Connection Management
connect()
Establish WebSocket connection to V8 Inspector.
import { connect } from "runtime:debugger";
// Connect with defaults (localhost:9229)await connect();
// Connect with custom optionsawait connect({ url: "ws://localhost:9229", timeout: 5000});Options:
url: Inspector WebSocket URL (default:ws://localhost:9229)timeout: Connection timeout in milliseconds (default: 3000)
Throws:
- Error [9601] if connection fails
- Error [9613] if connection times out
disconnect()
Close the inspector connection and cleanup resources.
import { disconnect } from "runtime:debugger";
await disconnect();isConnected()
Check if currently connected to the inspector.
import { isConnected } from "runtime:debugger";
if (await isConnected()) { console.log("Debugger is connected");}Breakpoint Management
setBreakpoint()
Set a breakpoint at a specific file and line.
import { setBreakpoint } from "runtime:debugger";
// Simple breakpointconst bp1 = await setBreakpoint("file:///src/main.ts", 42);
// Conditional breakpointconst bp2 = await setBreakpoint("file:///src/auth.ts", 100, { condition: "user.role === 'admin'"});
// Column-specific breakpointconst bp3 = await setBreakpoint("file:///src/utils.ts", 25, { column_number: 15, condition: "data.length > 1000"});Important: Line numbers are 0-based (line 1 in editor = lineNumber 0).
Parameters:
url: Script URL (must match exactly, e.g.,file:///src/main.ts)lineNumber: Line number (0-based)options: Optional breakpoint configurationcondition: JavaScript expression for conditional breakpointcolumn_number: Column number for precise breakpoint placement
Returns: Breakpoint with V8-assigned ID and actual location (may differ from requested if V8 adjusts to nearest executable statement).
Conditional Breakpoints
Conditional breakpoints only pause when the expression evaluates to truthy:
// Only pause when debugging is enabledawait setBreakpoint("file:///src/app.ts", 50, { condition: "config.debug === true"});
// Only pause for specific userawait setBreakpoint("file:///src/handlers.ts", 75, { condition: "request.userId === '12345'"});
// Only pause when array is largeawait setBreakpoint("file:///src/process.ts", 120, { condition: "items.length > 100"});removeBreakpoint()
Remove a breakpoint by ID.
import { setBreakpoint, removeBreakpoint } from "runtime:debugger";
const bp = await setBreakpoint("file:///src/main.ts", 42);await removeBreakpoint(bp.id);removeAllBreakpoints()
Remove all active breakpoints.
import { removeAllBreakpoints } from "runtime:debugger";
await removeAllBreakpoints();console.log("All breakpoints cleared");listBreakpoints()
Get all active breakpoints with metadata.
import { listBreakpoints } from "runtime:debugger";
const breakpoints = await listBreakpoints();for (const bp of breakpoints) { console.log(`${bp.id}: ${bp.location.script_id}:${bp.location.line_number}`); console.log(` Enabled: ${bp.enabled}, Hit count: ${bp.hit_count}`); if (bp.condition) { console.log(` Condition: ${bp.condition}`); }}enableBreakpoint() / disableBreakpoint()
Toggle breakpoints without removing them.
import { setBreakpoint, disableBreakpoint, enableBreakpoint } from "runtime:debugger";
const bp = await setBreakpoint("file:///src/main.ts", 42);
// Temporarily disableawait disableBreakpoint(bp.id);console.log("Breakpoint disabled");
// Re-enable laterawait enableBreakpoint(bp.id);console.log("Breakpoint enabled");Execution Control
pause()
Pause execution at the current statement.
import { pause } from "runtime:debugger";
await pause();console.log("Execution will pause at next statement");resume()
Resume execution from paused state.
import { resume, onPaused } from "runtime:debugger";
const cleanup = onPaused(async (event) => { console.log("Paused, resuming..."); await resume();});Step Operations
Control step-by-step execution:
import { stepOver, stepInto, stepOut } from "runtime:debugger";
// Execute current line, pause at next lineawait stepOver();
// Enter function callawait stepInto();
// Exit current functionawait stepOut();Step Behavior:
stepOver(): Execute current line completely, pause at next line in same functionstepInto(): If current line is a function call, enter the function; otherwise same as stepOverstepOut(): Execute until current function returns, pause in calling function
continueToLocation()
Continue execution until reaching a specific location (run-to-cursor).
import { continueToLocation } from "runtime:debugger";
// Continue to line 100 in current scriptawait continueToLocation({ script_id: "42", line_number: 100});setPauseOnExceptions()
Configure when to pause on exceptions.
import { setPauseOnExceptions } from "runtime:debugger";
// Never pause on exceptionsawait setPauseOnExceptions("none");
// Pause only on uncaught exceptionsawait setPauseOnExceptions("uncaught");
// Pause on all exceptions (including caught)await setPauseOnExceptions("all");States:
"none": Normal execution, let error handlers work"uncaught": Find exceptions that crash the app"all": Debug exception handling logic, trace error propagation
Stack Inspection
getCallFrames()
Retrieve the complete call stack when paused.
import { onPaused, getCallFrames } from "runtime:debugger";
const cleanup = onPaused(async (event) => { const frames = await getCallFrames();
console.log("Call stack:"); for (let i = 0; i < frames.length; i++) { const frame = frames[i]; console.log(`${i}: ${frame.function_name} at ${frame.url}:${frame.location.line_number}`); }});getScopeChain()
Get the scope chain for a specific call frame.
import { onPaused, getScopeChain } from "runtime:debugger";
const cleanup = onPaused(async (event) => { const topFrame = event.call_frames[0]; const scopes = await getScopeChain(topFrame.call_frame_id);
console.log("Scope chain:"); for (const scope of scopes) { console.log(`- ${scope.type}: ${scope.name || '(anonymous)'}`); }});Scope Types:
global: Global scopelocal: Function local scopeclosure: Closure scopecatch: Catch block scopeblock: Block scopescript: Script scopeeval: Eval scopemodule: Module scopewasmExpressionStack: WebAssembly expression stack
Object Inspection
getProperties()
Fetch properties of a remote object.
import { onPaused, getProperties } from "runtime:debugger";
const cleanup = onPaused(async (event) => { const topFrame = event.call_frames[0]; const localScope = topFrame.scope_chain.find(s => s.type === "local");
if (localScope?.object.object_id) { const props = await getProperties(localScope.object.object_id);
console.log("Local variables:"); for (const prop of props) { if (prop.value) { console.log(` ${prop.name}: ${prop.value.description} (${prop.value.type})`); } } }});Property Types:
- Data properties: Regular properties with values
- Accessor properties: Getter/setter properties
- Internal properties: V8 internal properties (prefixed with
[[]])
Remote Objects
V8 uses two representations for values:
Primitives (sent inline):
{ type: "number", value: 42, description: "42"}Complex Objects (require getProperties):
{ type: "object", subtype: "array", object_id: "obj-123", // Use this to fetch properties description: "Array(5)", preview: { /* optional preview */ }}Expression Evaluation
evaluate()
Execute arbitrary JavaScript expressions.
import { evaluate, onPaused } from "runtime:debugger";
// Global evaluationconst result1 = await evaluate("1 + 2 + 3");console.log(result1.value); // 6
// Evaluate in call frame context (when paused)const cleanup = onPaused(async (event) => { const topFrame = event.call_frames[0];
// Access local variables const result2 = await evaluate("localVar * 2", topFrame.call_frame_id); console.log(`localVar * 2 = ${result2.value}`);
// Complex expressions const result3 = await evaluate(` users.filter(u => u.role === 'admin') .map(u => u.name) .join(', ') `, topFrame.call_frame_id); console.log(`Admins: ${result3.value}`);});Capabilities:
- Access and modify global state
- Access local variables and closures (in frame context)
- Call functions and produce side effects
- Return complex objects (via remote object reference)
setVariableValue()
Modify variable values during debugging.
import { setVariableValue, onPaused } from "runtime:debugger";
const cleanup = onPaused(async (event) => { const topFrame = event.call_frames[0];
// Modify local variable (scope 0) await setVariableValue(0, "counter", 100, topFrame.call_frame_id);
// Modify closure variable (scope 1) await setVariableValue(1, "config", { debug: true }, topFrame.call_frame_id);});Parameters:
scopeNumber: Index in scope chain (0 = local, higher = outer scopes)variableName: Name of variable to modifynewValue: New value to assigncallFrameId: Call frame ID from pause event
Script Management
listScripts()
List all loaded scripts.
import { listScripts } from "runtime:debugger";
const scripts = await listScripts();
// Filter to application scriptsconst appScripts = scripts.filter(s => s.url.startsWith("file://") && !s.url.includes("node_modules"));
console.log("Application scripts:");for (const script of appScripts) { console.log(` ${script.url}`); console.log(` ID: ${script.script_id}, Lines: ${script.start_line}-${script.end_line}`);}getScriptSource()
Retrieve source code by script ID.
import { getScriptSource, listScripts } from "runtime:debugger";
const scripts = await listScripts();const mainScript = scripts.find(s => s.url.endsWith("/main.ts"));
if (mainScript) { const source = await getScriptSource(mainScript.script_id); console.log(`Source of ${mainScript.url}:\n${source}`);}Event Handling
onPaused()
Listen for pause events.
import { onPaused, setBreakpoint, resume } from "runtime:debugger";
// Set up breakpointawait setBreakpoint("file:///src/main.ts", 42);
// Listen for pause eventsconst cleanup = onPaused(async (event) => { console.log(`Paused: ${event.reason}`);
// Handle different pause reasons switch (event.reason) { case "breakpoint": console.log("Hit breakpoint"); break; case "exception": console.log("Exception:", event.data?.exception?.description); break; case "debugCommand": console.log("Manual pause or step"); break; default: console.log("Other pause:", event.reason); }
// Print stack trace for (const frame of event.call_frames) { console.log(` at ${frame.function_name} (${frame.url}:${frame.location.line_number})`); }
await resume();});
// Later: stop listeningcleanup();Pause Reasons:
"breakpoint": Breakpoint hit"exception": Exception thrown"promiseRejection": Unhandled promise rejection"debugCommand": Manual pause or step operation"assert": Assertion failed"OOM": Out of memory- And others…
onScriptParsed()
Listen for script loading events.
import { onScriptParsed, setBreakpoint } from "runtime:debugger";
// Auto-set breakpoints in new app modulesconst cleanup = onScriptParsed(async (script) => { if (script.url.startsWith("file://") && !script.url.includes("node_modules")) { console.log(`New script loaded: ${script.url}`);
// Set breakpoint at first line await setBreakpoint(script.url, 0); }});
// Later: stop listeningcleanup();Advanced Patterns
Interactive Debugging REPL
import * as debugger from "runtime:debugger";import * as readline from "node:readline/promises";
await debugger.connect();await debugger.setBreakpoint("file:///src/main.ts", 42);
const rl = readline.createInterface({ input: Deno.stdin, output: Deno.stdout});
const cleanup = debugger.onPaused(async (event) => { console.log(`\nPaused at ${event.call_frames[0].url}:${event.call_frames[0].location.line_number}`);
while (true) { const command = await rl.question("debug> ");
if (command === "continue" || command === "c") { await debugger.resume(); break; } else if (command === "step" || command === "s") { await debugger.stepOver(); break; } else if (command === "locals") { const topFrame = event.call_frames[0]; const localScope = topFrame.scope_chain.find(s => s.type === "local"); if (localScope?.object.object_id) { const props = await debugger.getProperties(localScope.object.object_id); for (const prop of props) { if (prop.value) { console.log(` ${prop.name} = ${prop.value.description}`); } } } } else if (command.startsWith("eval ")) { const expr = command.slice(5); const result = await debugger.evaluate(expr, event.call_frames[0].call_frame_id); console.log(` => ${result.description || result.value}`); } }});Code Coverage Tracking
import { onScriptParsed, listScripts } from "runtime:debugger";
const loadedScripts = new Set<string>();
const cleanup = onScriptParsed((script) => { if (script.url.startsWith("file://")) { loadedScripts.add(script.url); }});
// Later: analyze coveragesetTimeout(async () => { const allScripts = await listScripts(); const appScripts = allScripts.filter(s => s.url.startsWith("file://"));
console.log(`Loaded: ${loadedScripts.size}/${appScripts.length} scripts`);
const notLoaded = appScripts.filter(s => !loadedScripts.has(s.url)); if (notLoaded.length > 0) { console.log("\nNever loaded:"); for (const script of notLoaded) { console.log(` ${script.url}`); } }}, 10000);Watchpoint Simulation
import { onPaused, evaluate, setBreakpoint, resume } from "runtime:debugger";
// Watch a variable by setting conditional breakpointawait setBreakpoint("file:///src/app.ts", 50, { condition: "oldValue !== currentValue"});
const cleanup = onPaused(async (event) => { const oldValue = await evaluate("oldValue", event.call_frames[0].call_frame_id); const newValue = await evaluate("currentValue", event.call_frames[0].call_frame_id);
console.log(`Variable changed: ${oldValue.value} => ${newValue.value}`);
await resume();});Error Handling
Error Codes
| Code | Error | Description |
|---|---|---|
| 9600 | Generic | Generic debugger error |
| 9601 | ConnectionFailed | Failed to connect to inspector |
| 9602 | NotConnected | Not connected to inspector |
| 9603 | BreakpointFailed | Breakpoint operation failed |
| 9604 | InvalidFrameId | Invalid frame ID |
| 9605 | InvalidScopeId | Invalid scope ID |
| 9606 | EvaluationFailed | Expression evaluation failed |
| 9607 | SourceNotFound | Script/source not found |
| 9608 | StepFailed | Step operation failed |
| 9609 | PauseFailed | Pause operation failed |
| 9610 | ResumeFailed | Resume operation failed |
| 9611 | ProtocolError | Protocol error from V8 |
| 9612 | NotEnabled | Inspector not enabled |
| 9613 | Timeout | Operation timeout |
| 9614 | InvalidLocation | Invalid breakpoint location |
Error Handling Patterns
import { connect, setBreakpoint } from "runtime:debugger";
// Connection errorstry { await connect({ timeout: 1000 });} catch (error) { if (error.message.includes("[9601]")) { console.error("Inspector not available - is --inspect enabled?"); } else if (error.message.includes("[9613]")) { console.error("Connection timed out"); }}
// Breakpoint errorstry { await setBreakpoint("file:///src/missing.ts", 100);} catch (error) { if (error.message.includes("[9614]")) { console.error("Invalid breakpoint location"); } else if (error.message.includes("[9602]")) { console.error("Not connected to debugger"); }}
// Evaluation errorstry { const result = await evaluate("nonexistent.property");} catch (error) { if (error.message.includes("[9606]")) { console.error("Evaluation failed:", error); }}Best Practices
1. Always Clean Up Event Listeners
// Good: Store and call cleanup functionconst cleanup = onPaused((event) => { console.log("Paused");});
// Latercleanup();
// Bad: No cleanup (memory leak)onPaused((event) => { console.log("Paused");});2. Handle Connection State
// Good: Check connection before operationsif (await isConnected()) { await setBreakpoint("file:///src/main.ts", 42);} else { await connect(); await setBreakpoint("file:///src/main.ts", 42);}
// Bad: Assume always connectedawait setBreakpoint("file:///src/main.ts", 42); // May throw [9602]3. Use Conditional Breakpoints for Efficiency
// Good: Condition in breakpoint (evaluated by V8)await setBreakpoint("file:///src/loop.ts", 10, { condition: "i === 1000" // Only pause once});
// Bad: Manual condition check (pauses 1000 times)await setBreakpoint("file:///src/loop.ts", 10);onPaused(async (event) => { const i = await evaluate("i", event.call_frames[0].call_frame_id); if (i.value === 1000) { // Do something } await resume();});4. Fetch Only Needed Properties
// Good: Fetch specific propertiesconst props = await getProperties(objectId);const neededProps = props.filter(p => ["name", "email", "role"].includes(p.name));
// Bad: Fetch all properties of large objectsconst allProps = await getProperties(largeObjectId); // Slow for huge objects5. Use 0-Based Line Numbers Correctly
// Good: Remember 0-based indexingconst editorLine = 42; // Line 42 in editor (1-based)await setBreakpoint("file:///src/main.ts", editorLine - 1); // Line 41 (0-based)
// Bad: Direct use of editor line numberawait setBreakpoint("file:///src/main.ts", 42); // Will be line 43 in editor!Common Pitfalls
Pitfall 1: Forgetting Async/Await
// Wrong: Missing awaitconst bp = setBreakpoint("file:///src/main.ts", 42); // Returns Promiseconsole.log(bp.id); // undefined - bp is a Promise!
// Correct: Use awaitconst bp = await setBreakpoint("file:///src/main.ts", 42);console.log(bp.id); // Correct breakpoint IDPitfall 2: Not Handling V8 Breakpoint Adjustment
// Request breakpoint at line 10 (comment line)const bp = await setBreakpoint("file:///src/main.ts", 10);
// V8 may adjust to nearest executable statementconsole.log(`Requested: 10, Actual: ${bp.location.line_number}`);// Output: "Requested: 10, Actual: 12" (adjusted to next code line)Pitfall 3: Blocking in Pause Handler
// Wrong: Blocking operation in pause handleronPaused(async (event) => { while (true) { // This blocks the event loop! await someSlowOperation(); }});
// Correct: Resume to allow execution to continueonPaused(async (event) => { console.log("Paused"); await resume(); // Always resume or step});Pitfall 4: Incorrect Remote Object Inspection
// Wrong: Trying to access value directlyconst localScope = frame.scope_chain.find(s => s.type === "local");console.log(localScope.object.value); // undefined for complex objects!
// Correct: Use getProperties for complex objectsif (localScope?.object.object_id) { const props = await getProperties(localScope.object.object_id); console.log(props);}Performance Considerations
WebSocket Latency
Each debugger operation requires round-trip WebSocket communication (~1ms localhost):
// Slow: 3 round tripsconst frames = await getCallFrames(); // ~1msconst scopes = await getScopeChain(frames[0].call_frame_id); // ~1msconst props = await getProperties(scopes[0].object.object_id); // ~1ms// Total: ~3ms
// Faster: Batch operations when possibleconst cleanup = onPaused(async (event) => { // Use event.call_frames instead of getCallFrames() const topFrame = event.call_frames[0]; const scopes = topFrame.scope_chain; // Already included!
if (scopes[0].object.object_id) { const props = await getProperties(scopes[0].object.object_id); // Only 1 round trip }});Large Object Inspection
Fetching properties of large objects can be slow:
// Slow: Fetch all properties of huge objectconst allProps = await getProperties(hugeArrayId); // May take 100ms+
// Faster: Fetch only needed propertiesconst allProps = await getProperties(hugeArrayId);const needed = allProps.slice(0, 10); // Only use first 10Event Frequency
High-frequency events can overwhelm listeners:
// Problematic: Pause in tight loopawait setBreakpoint("file:///src/loop.ts", 5); // Inside looponPaused(async (event) => { console.log("Paused"); // Will fire 1000+ times! await resume();});
// Better: Use conditional breakpointawait setBreakpoint("file:///src/loop.ts", 5, { condition: "i % 100 === 0" // Only pause every 100 iterations});Troubleshooting
”Not connected” Errors
Problem: Error [9602]: Not connected to debugger
Solutions:
// Ensure connection before operationsif (!await isConnected()) { await connect();}
// Or use try-catchtry { await setBreakpoint("file:///src/main.ts", 42);} catch (error) { if (error.message.includes("[9602]")) { await connect(); await setBreakpoint("file:///src/main.ts", 42); }}“Connection failed” Errors
Problem: Error [9601]: Failed to connect to inspector
Causes:
- V8 Inspector not enabled (missing
--inspectflag) - Inspector running on different port
- Inspector already connected by another client
Solutions:
// Check inspector is enabled// Run with: deno run --inspect script.ts
// Try different portawait connect({ url: "ws://localhost:9230" });
// Check for existing connections// Close Chrome DevTools if openBreakpoint Not Hit
Problem: Breakpoint set but never triggers
Common Causes:
-
Wrong URL format
// Wrong: Relative pathawait setBreakpoint("src/main.ts", 42);// Correct: Absolute file:// URLawait setBreakpoint("file:///path/to/src/main.ts", 42); -
Code never executed
// Breakpoint in dead code won't triggerif (false) {console.log("This never runs"); // Breakpoint here won't hit} -
Line number off-by-one
// Remember: 0-based indexingawait setBreakpoint("file:///src/main.ts", 41); // Line 42 in editor
Evaluation Failures
Problem: Error [9606]: Evaluation failed
Causes:
- Syntax error in expression
- Variable doesn’t exist in scope
- Exception thrown during evaluation
Solutions:
try { const result = await evaluate("nonexistent.property");} catch (error) { console.error("Evaluation failed:", error.message); // Try simpler expression or check variable exists}See Also
- ext_devtools - DevTools frontend integration
- ext_trace - Application tracing and profiling
- Architecture - Forge system architecture
- Chrome DevTools Protocol
- V8 Inspector Protocol