feat: add native VS Code actor debugging

Attach an inline DAP session to one actor with exact loaded sources, gutter breakpoint verification, call stacks and bounded toolbar controls. Preserve explicit scheduler ticks, guard connection and code-version changes, and leave actors unchanged on detach.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 21:36:30 +01:00
parent 68afb435f3
commit c2781a9bb8
10 changed files with 1325 additions and 9 deletions

View File

@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file.
### Added
- Native VS Code **Debug Actor** attach sessions with exact loaded sources,
gutter breakpoint verification, call stacks, pause/continue and bounded toolbar
stepping. Sessions terminate on actor removal, implementation/incarnation change
or image disconnect; detaching never kills or implicitly resumes the actor.
- Bounded source step-over and step-out through the runtime, image API, protocol,
and VS Code actor commands, preserving nested breakpoints and async stop boundaries.

View File

@ -34,8 +34,10 @@ callbacks are not preemptible. The debugger foundation exposes bounded active Ru
stack frames and exact loaded source through read-only protocol inspection and
VS Code's **Inspect Execution Stack...** actor action. Actor-local source
breakpoints and bounded step-into/over/out are implemented through the protocol and VS
Code actor actions; locals, DAP, and persistent storage remain
unimplemented.
Code actor actions. **Debug Actor** attaches a native VS Code debug session with
gutter breakpoints, the call-stack view, and bounded toolbar stepping. Continue
resumes the actor; scheduler ticks remain explicit. Locals, expression evaluation,
and persistent storage remain unimplemented.
## Design goals

View File

@ -538,10 +538,15 @@ Debugger foundations now expose bounded head-VM stack frames and UTF-16 source
locations through read-only execution inspection. VS Code navigates immutable
exact-loaded-source documents, including old code during pending replacements.
Await locations represent the last safe snapshot; native/hidden async frames,
locals, post-mortem stacks, and DAP remain unimplemented. Actor-specific
locals and post-mortem stacks remain unimplemented. Actor-specific
implementation-bound line breakpoints and bounded step-into/over/out now preserve active
drafts/effects and use exact loaded-source mappings. These are command-based
controls, not gutter breakpoints; conditional stops remain planned.
drafts/effects and use exact loaded-source mappings. An attach-only inline DAP
adapter now exposes one actor as one VS Code debug thread with exact loaded
sources, gutter breakpoints, call stacks, pause/resume, and bounded toolbar
stepping. Continue does not implicitly tick the shared scheduler. Sessions bind
to the connection, implementation and incarnation; a change terminates the
session rather than remapping live frames. Detach leaves actor status and
breakpoints unchanged. Locals/evaluation and conditional stops remain planned.
Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport.

View File

@ -186,9 +186,9 @@ Failure is surfaced with its error text; successful shutdown may remove the acto
Same-line loops are bounded and pending awaits return immediately.
Installation clears breakpoints to prevent stale source mappings; pending edits
do not. Breakpoints belong to the actor/image, so disconnect does not clear
them. Restart on unchanged code retains them. These are command-based controls,
not editor-gutter breakpoints or an F5/debug-toolbar session. Conditions, hit
counts, and variables remain planned.
them. Restart on unchanged code retains them. These commands also remain
available independently of the native debugger described below. Conditions,
hit counts, and variables remain planned.
For automation, `runa.setBreakpoints` accepts `{actor_ref: string}` and optional
comma-separated 1-based line text (including `""` to clear). `runa.stepSource`
@ -196,6 +196,72 @@ accepts `{actor_ref: string}` and returns its stop reason.
`runa.stepOver` and `runa.stepOut` accept the same argument and return stop reasons.
Use a server built with mode-aware stepping for these new commands.
### Native actor debugger
Connect to an image, then right-click an actor and choose **Debug Actor**.
Alternatively choose **Runa Actor** in Run and Debug and use an attach
configuration (omit `actorRef` to choose an actor):
```json
{
"type": "runa",
"request": "attach",
"name": "Attach to Runa Actor"
}
```
**Debug Actor** does not save open editors. Starting an attach configuration
directly through Run and Debug uses VS Code's `debug.saveBeforeStart` setting.
The extension's inline Debug Adapter Protocol adapter uses the existing image
connection; it does not start a server, spawn an actor, or open a separate debug
port. Each session represents one actor/thread and is bound to the current
connection, installed implementation, and incarnation. Only one session per
actor is allowed. Attach pauses a live nonfailed actor, retaining its active
invocation if any. An idle actor can be attached, but has no frames until it
starts a handler. Failed actors must be repaired/restarted before attachment.
- Set gutter breakpoints in the actor's immutable loaded source, opened by the
command or Call Stack view. The debugger serves the exact compiled text,
including temporary/older implementations, using source references.
Breakpoints in the image source editor are accepted only when both the saved
image file and any open editor buffer exactly match the loaded text. Other,
stale, or unsaved-different files are unverified, not silently mapped to code.
Changing a working document's breakpoint list removes that document's old
locations if it no longer matches. Loaded and matching working documents'
breakpoint groups are combined, with at most 128 distinct lines per actor.
Blank/nonexecutable lines remain unverified. Conditions, hit counts, and
logpoints are unsupported.
- **Continue** resumes scheduling eligibility but does **not** execute image
ticks. Send a message and use **Runa: Advance Scheduler** to run it toward a
breakpoint. Ordinary ticks can advance all eligible actors. **Pause** pauses
only the selected actor between ticks.
- Toolbar **Step Into**, **Step Over**, and **Step Out** use the existing bounded
source steps (1,000 instruction polls) and advance only this actor. A pending
await, breakpoint, budget, completion, or failure ends the request. The Debug
Console reports the exact reason; a surviving nonfailed actor stays paused.
Steps require an active invocation and never consume the next queued message.
- Call Stack displays at most the innermost 64 head-VM frames; truncation and
last-safe-await snapshots are identified in the stop description. Unmapped
frames have no invented source location. At handler completion the stack is
empty, state commits normally, and the actor remains paused. A failure shows
an exception stop with its error text, not a post-mortem stack.
- Variables/scopes, watches/evaluation, restart, instruction-level stepping,
exception filters, and native/hidden async frames are not implemented. Rune
native calls remain nonpreemptible. Unsupported requests fail explicitly.
- Disconnecting/stopping the debug session leaves the actor's current status
and breakpoint list unchanged: it does not kill, resume, or restart the actor.
Actor removal, implementation installation, restart/incarnation change, or
image disconnect terminates the session with a console explanation. Attach
again to use the new version. Pending code edits alone do not change the
loaded version.
Breakpoint lists are actor/image state shared with other clients and the
standalone **Set Actor Breakpoints...** command; a later writer replaces the
runtime list. Native verification updates when an external change removes or
restores a session breakpoint. Disconnect does not clear them.
For automation, `runa.debugActor` accepts `{actor_ref: string}` and returns
whether VS Code started the session.
For automation, the `runa.connect` command also accepts an explicit endpoint
string, and `runa.inspect` accepts an object with an `actor_ref` field.
**Copy Actor Reference** in the actor context menu or inline button copies the

View File

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import * as vscode from "vscode";
import { control, spawnCounter, startImage } from "./server";
import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider";
import { ImageSourceTree, RevisionDocuments, sourceFileUri } from "../src/sourceProvider";
import { actorFileUri } from "../src/actorFiles";
import type { activate } from "../src/extension";
import { SourceDiagnostics } from "../src/diagnostics";
@ -9,6 +9,7 @@ import { SourceDiagnostic } from "../src/protocol";
import { ImageClient } from "../src/client";
import { parseValue } from "../src/values";
import { ExecutionDocuments, frameSelection } from "../src/executionProvider";
import { DebugMessage } from "../src/debugger";
export async function run(): Promise<void> {
const image = await startImage();
@ -46,6 +47,7 @@ export async function run(): Promise<void> {
await checkSourceDiagnostics(image.address);
await inspectExecution(image.address);
await sourceDebugging(image.address);
await nativeDebugging(image.address);
await editActorFiles(image.address);
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
path: "actors/counter",
@ -405,6 +407,193 @@ async function sourceDebugging(address: string): Promise<void> {
}
}
async function nativeDebugging(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
const messages: DebugMessage[] = [];
const changes = new vscode.EventEmitter<void>();
const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", {
createDebugAdapterTracker: () => ({
onDidSendMessage: (message: DebugMessage) => {
messages.push(message);
changes.fire();
},
}),
});
const wait = (predicate: (message: DebugMessage) => boolean, since = 0) =>
new Promise<DebugMessage>((resolve, reject) => {
const check = () => {
const found = messages.slice(since).find(predicate);
if (!found) return;
clearTimeout(timer);
subscription.dispose();
resolve(found);
};
const subscription = changes.event(check);
const timer = setTimeout(() => {
subscription.dispose();
reject(new Error(`Native DAP message timed out: ${JSON.stringify(messages.slice(since))}`));
}, 10000);
check();
});
const selectedLine = (uri: vscode.Uri, line: number) =>
new Promise<void>((resolve, reject) => {
const check = () => {
const editor = vscode.window.activeTextEditor;
if (
editor?.document.uri.toString() !== uri.toString() ||
editor.selection.start.line !== line
)
return;
clearTimeout(timer);
subscriptions.forEach((subscription) => subscription.dispose());
resolve();
};
const subscriptions = [
vscode.window.onDidChangeActiveTextEditor(check),
vscode.window.onDidChangeTextEditorSelection(check),
];
const timer = setTimeout(() => {
subscriptions.forEach((subscription) => subscription.dispose());
reject(new Error(`Native debugger did not navigate to source line ${line + 1}`));
}, 5000);
check();
});
let breakpoint: vscode.SourceBreakpoint | undefined;
let session: vscode.DebugSession | undefined;
try {
const source =
"pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}";
const file = await client.writeSource("tests/native_debugging", null, source);
const actor = await client.spawn(file.path, parseValue("10"));
const other = await client.spawn(file.path, parseValue("20"));
await vscode.commands.executeCommand("runa.refresh");
const working = await vscode.workspace.openTextDocument(sourceFileUri(address, file.path));
await replaceText(working, source + "\n// unsaved edit");
assert.equal(
await vscode.commands.executeCommand("runa.debugActor", { actor_ref: actor }),
true,
);
assert.equal(working.isDirty, true, "Debug Actor must not save open editors");
assert.equal((await client.readSource(file.path)).source, source);
await replaceText(working, source);
await working.save();
await wait((message) => message.event === "stopped");
session = vscode.debug.activeDebugSession;
assert.ok(session);
assert.equal(session.type, "runa");
assert.equal((await client.inspectActor(actor)).status.status, "paused");
const loaded: { sources: { name: string; path: string; sourceReference: number }[] } =
await session.customRequest("loadedSources");
const debugSource = loaded.sources[0];
assert.ok(debugSource);
const uri = vscode.debug.asDebugSourceUri(debugSource, session);
const document = await vscode.workspace.openTextDocument(uri);
assert.equal(document.getText(), source);
assert.equal(document.languageId, "rune");
breakpoint = new vscode.SourceBreakpoint(new vscode.Location(uri, new vscode.Position(1, 0)));
let since = messages.length;
vscode.debug.addBreakpoints([breakpoint]);
const response = await wait(
(message) => message.type === "response" && message.command === "setBreakpoints",
since,
);
assert.equal(response.success, true);
assert.equal(
(response.body as { breakpoints: { verified: boolean }[] }).breakpoints[0]?.verified,
true,
);
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.continue");
await wait((message) => message.event === "continued", since);
assert.equal(
(await client.inspectActor(actor)).execution,
null,
"continue must not tick the image",
);
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.pause");
await wait((message) => message.event === "stopped", since);
assert.equal((await client.inspectActor(actor)).status.status, "paused");
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.continue");
await wait((message) => message.event === "continued", since);
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await wait((message) => message.event === "stopped", since);
assert.equal((await client.inspectExecution(actor)).debugger?.stop_reason, "breakpoint");
const otherBefore = await client.inspectActor(other);
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.stepInto");
await wait((message) => message.event === "stopped", since);
let stack: {
stackFrames: { id: number; name: string; line: number; source: vscode.DebugProtocolSource }[];
} = await session.customRequest("stackTrace", { threadId: 1 });
assert.equal(stack.stackFrames[0]?.name, "nested");
assert.equal(stack.stackFrames[0]?.line, 6);
await selectedLine(uri, 5);
assert.deepEqual(await client.inspectActor(other), otherBefore);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
assert.deepEqual(await session.customRequest("scopes", { frameId: stack.stackFrames[0]!.id }), {
scopes: [],
});
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.stepOut");
await wait((message) => message.event === "stopped", since);
stack = await session.customRequest("stackTrace", { threadId: 1 });
assert.equal(stack.stackFrames[0]?.name, "handle");
assert.equal(stack.stackFrames[0]?.line, 3);
await selectedLine(uri, 2);
for (let count = 0; count < 10 && (await client.inspectActor(actor)).execution; count++) {
since = messages.length;
await vscode.commands.executeCommand("workbench.action.debug.stepOver");
await wait((message) => message.event === "stopped", since);
}
const completed = await client.inspectActor(actor);
assert.equal(completed.execution, null);
assert.equal(completed.status.status, "paused");
assert.deepEqual(completed.state, parseValue("12"));
assert.deepEqual(await session.customRequest("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
since = messages.length;
vscode.debug.removeBreakpoints([breakpoint]);
breakpoint = undefined;
await wait(
(message) => message.type === "response" && message.command === "setBreakpoints",
since,
);
assert.deepEqual((await client.inspectExecution(actor)).debugger?.breakpoints, []);
since = messages.length;
await vscode.debug.stopDebugging(session);
await wait((message) => message.event === "terminated", since);
assert.equal((await client.inspectActor(actor)).status.status, "paused");
since = messages.length;
assert.equal(
await vscode.commands.executeCommand("runa.debugActor", { actor_ref: actor }),
true,
);
await wait((message) => message.event === "stopped", since);
session = vscode.debug.activeDebugSession;
since = messages.length;
await vscode.commands.executeCommand("runa.disconnect");
await wait((message) => message.event === "terminated", since);
assert.equal((await client.inspectActor(actor)).status.status, "paused");
await vscode.commands.executeCommand("runa.connect", address);
await client.system(actor, { kind: "kill" });
await client.system(other, { kind: "kill" });
console.log(
"Runa native debugger: attach, gutter breakpoint, real toolbar stepping, exact sources, isolation and detach passed",
);
} finally {
if (breakpoint) vscode.debug.removeBreakpoints([breakpoint]);
if (session) await vscode.debug.stopDebugging(session);
tracker.dispose();
changes.dispose();
await client.close();
}
}
async function editSources(address: string): Promise<void> {
const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa",

View File

@ -13,6 +13,7 @@
],
"main": "./out/src/extension.js",
"activationEvents": [
"onDebug:runa",
"onFileSystem:runa-actor",
"onView:runa.actors",
"onView:runa.sources",
@ -39,6 +40,37 @@
"typescript": "^5.9.3"
},
"contributes": {
"debuggers": [
{
"type": "runa",
"label": "Runa Actor",
"languages": [
"rune"
],
"configurationAttributes": {
"attach": {
"properties": {
"actorRef": {
"type": "string",
"description": "Actor reference in the connected image. Omit to choose an actor."
}
}
}
},
"initialConfigurations": [
{
"type": "runa",
"request": "attach",
"name": "Attach to Runa Actor"
}
]
}
],
"breakpoints": [
{
"language": "rune"
}
],
"languages": [
{
"id": "rune",
@ -47,10 +79,20 @@
],
"extensions": [
".rn"
],
"mimetypes": [
"text/x-rune"
]
}
],
"commands": [
{
"command": "runa.debugActor",
"title": "Debug Actor",
"category": "Runa",
"enablement": "runa.connected",
"icon": "$(debug-alt)"
},
{
"command": "runa.stepOver",
"title": "Step Over Source Line",
@ -335,6 +377,11 @@
}
],
"view/item/context": [
{
"command": "runa.debugActor",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
"group": "debug@0"
},
{
"command": "runa.stepOver",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",

507
vscode/src/debugger.ts Normal file
View File

@ -0,0 +1,507 @@
import { ImageClient } from "./client";
import {
ExecutionInspection,
isActorRef,
SourceBreakpoint,
StackFrame,
StepMode,
} from "./protocol";
import { RefreshQueue } from "./refresh";
interface Request {
seq: number;
type: "request";
command: string;
arguments?: Record<string, unknown>;
}
export interface DebugMessage {
seq: number;
type: "response" | "event";
request_seq?: number;
command?: string;
success?: boolean;
message?: string;
event?: string;
body?: unknown;
}
interface DebugSource {
name: string;
path: string;
sourceReference: number;
}
interface BreakpointEntry {
id: number;
line: number;
unsupported: boolean;
sourceMatches: boolean;
}
interface BreakpointGroup {
source: Record<string, unknown>;
entries: BreakpointEntry[];
}
type DebugClient = Pick<
ImageClient,
| "inspectActor"
| "inspectExecution"
| "listActors"
| "system"
| "readSource"
| "setBreakpoints"
| "stepSource"
>;
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error("Expected a debug protocol object");
return value as Record<string, unknown>;
}
function integer(value: unknown, minimum = 0): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum)
throw new Error("Expected a debug protocol integer");
return value;
}
/** One actor, one installed implementation/incarnation, and no implicit scheduler ticks. */
export class ActorDebugAdapter {
private sequence = 0;
private queue: Promise<void> = Promise.resolve();
private closed = false;
private attached = false;
private configured = false;
private lineBase = 1;
private columnBase = 1;
private inspection?: ExecutionInspection;
private source?: DebugSource;
private readonly breakpointGroups = new Map<string, BreakpointGroup>();
private readonly breakpointStates = new Map<number, boolean>();
private readonly frames = new Map<number, StackFrame>();
private nextFrame = 0;
private nextBreakpoint = 0;
private stopKey?: string;
private paused = false;
private readonly updates: RefreshQueue;
constructor(
private readonly client: DebugClient,
readonly actorRef: string,
private readonly publishSource: (inspection: ExecutionInspection) => string,
private readonly checkConnection: () => void,
private readonly send: (message: DebugMessage) => void,
private readonly workingSourcePath: (path: string) => string,
private readonly visibleSourceMatches: (path: string, source: string) => boolean,
) {
if (!isActorRef(actorRef)) throw new Error("Invalid debug actor reference");
this.updates = new RefreshQueue(
() => this.enqueue(() => this.synchronize()),
(error) => this.terminate(error instanceof Error ? error.message : String(error)),
);
}
handleMessage(message: unknown): void {
let request: Request;
try {
const input = record(message);
if (input.type !== "request" || typeof input.command !== "string")
throw new Error("Expected a debug protocol request");
request = {
seq: integer(input.seq),
type: "request",
command: input.command,
arguments: input.arguments === undefined ? undefined : record(input.arguments),
};
} catch (error) {
this.terminate(error instanceof Error ? error.message : String(error));
return;
}
void this.enqueue(async () => {
try {
const result = await this.dispatch(request);
this.emit({
type: "response",
request_seq: request.seq,
command: request.command,
success: true,
body: result.body,
});
result.after?.();
} catch (error) {
this.emit({
type: "response",
request_seq: request.seq,
command: request.command,
success: false,
message: error instanceof Error ? error.message : String(error),
});
if (request.command === "attach") this.terminate();
}
});
}
invalidate(): void {
if (this.attached && this.configured) this.updates.invalidate();
}
terminate(message?: string): void {
if (this.closed) return;
if (message) this.output(message);
this.event("terminated");
this.dispose();
}
dispose(): void {
this.closed = true;
this.updates.dispose();
this.frames.clear();
this.breakpointGroups.clear();
this.breakpointStates.clear();
}
private enqueue(action: () => Promise<void>): Promise<void> {
const result = this.queue.then(async () => {
if (!this.closed) await action();
});
this.queue = result.catch((error: unknown) =>
this.terminate(error instanceof Error ? error.message : String(error)),
);
return result;
}
private emit(message: Omit<DebugMessage, "seq">): void {
if (!this.closed) this.send({ seq: ++this.sequence, ...message });
}
private event(event: string, body?: unknown): void {
this.emit({ type: "event", event, body });
}
private output(message: string): void {
this.event("output", { category: "console", output: `Runa: ${message}\n` });
}
private check(): void {
if (this.closed) throw new Error("Debug session ended");
this.checkConnection();
}
private async read(): Promise<ExecutionInspection> {
this.check();
const inspection = await this.client.inspectExecution(this.actorRef);
this.check();
if (
this.inspection &&
(inspection.implementation_id !== this.inspection.implementation_id ||
inspection.incarnation !== this.inspection.incarnation)
) {
this.terminate("Actor implementation or incarnation changed; attach again.");
throw new Error("Actor implementation or incarnation changed");
}
this.inspection = inspection;
if (!this.source) {
this.source = {
name: `${inspection.behavior_path ?? "loaded behavior"}.rn`,
path: this.publishSource(inspection),
sourceReference: 1,
};
}
return inspection;
}
private async synchronize(): Promise<void> {
if (!this.attached || !this.configured) return;
this.check();
const actors = await this.client.listActors();
this.check();
const actor = actors.find((actor) => actor.actor_ref === this.actorRef);
if (!actor) {
this.terminate("Actor removed.");
return;
}
const inspection = await this.read();
this.check();
this.verifyBreakpoints(inspection.debugger?.breakpoints ?? []);
const paused = actor.status.status === "paused" || actor.status.status === "failed";
if (!paused) {
if (this.paused) this.event("continued", { threadId: 1, allThreadsContinued: true });
this.paused = false;
this.stopKey = undefined;
this.frames.clear();
return;
}
const execution = inspection.execution;
const key = `${actor.status.status}:${execution?.vm_steps}:${execution?.frames[0]?.instruction_pointer}:${inspection.debugger?.stop_reason}`;
if (key === this.stopKey) return;
this.stopKey = key;
this.paused = true;
this.frames.clear();
for (const frame of execution?.frames ?? []) this.frames.set(++this.nextFrame, frame);
const reason = inspection.debugger?.stop_reason;
this.event("stopped", {
reason:
actor.status.status === "failed"
? "exception"
: reason === "breakpoint"
? "breakpoint"
: reason === "step"
? "step"
: "pause",
description:
actor.status.message ??
(execution
? `${reason ?? "paused"}${execution.awaiting ? " (last safe await snapshot)" : ""}${execution.frames_truncated ? "; innermost 64 frames shown" : ""}`
: "Paused at handler boundary; no active invocation"),
threadId: 1,
allThreadsStopped: true,
});
}
private async dispatch(request: Request): Promise<{ body?: unknown; after?: () => void }> {
const args = request.arguments ?? {};
if (request.command === "initialize") {
this.lineBase = args.linesStartAt1 === false ? 0 : 1;
this.columnBase = args.columnsStartAt1 === false ? 0 : 1;
return {
body: {
supportsConfigurationDoneRequest: true,
supportsLoadedSourcesRequest: true,
supportsTerminateDebuggee: false,
supportSuspendDebuggee: false,
},
};
}
if (request.command === "disconnect") return { after: () => this.terminate() };
if (request.command === "attach") {
if (this.attached) throw new Error("Debugger is already attached");
this.check();
const actor = await this.client.inspectActor(this.actorRef);
this.check();
if (actor.status.status === "failed" || actor.status.status === "stopped")
throw new Error("Attach requires a live, nonfailed actor");
await this.read();
if (actor.status.status !== "paused") {
this.check();
await this.client.system(this.actorRef, { kind: "pause" });
this.check();
}
this.attached = true;
return {
after: () => {
this.output(
"Attached to one actor. Continue resumes scheduling eligibility but does not tick the image; use Runa: Advance Scheduler. Disconnect leaves actor status and breakpoints unchanged. Locals/evaluation are not available.",
);
this.event("initialized");
},
};
}
if (!this.attached) throw new Error("Attach to an actor first");
this.check();
switch (request.command) {
case "configurationDone":
this.configured = true;
return { after: () => this.invalidate() };
case "threads":
return { body: { threads: [{ id: 1, name: `@${this.actorRef}` }] } };
case "loadedSources":
return { body: { sources: this.source ? [this.source] : [] } };
case "source": {
if (args.sourceReference !== 1) throw new Error("Unknown loaded source");
return { body: { content: this.inspection?.source, mimeType: "text/x-rune" } };
}
case "setBreakpoints":
return { body: await this.setBreakpoints(args) };
case "setExceptionBreakpoints":
if (Array.isArray(args.filters) && args.filters.length === 0)
return { body: { breakpoints: [] } };
throw new Error("Exception filters are not supported");
case "stackTrace": {
this.thread(args);
const frames = [...this.frames].map(([id, frame]) => ({
id,
name: frame.function ?? "<unknown function>",
source: frame.range ? this.source : undefined,
line: frame.range ? frame.range.start.line + this.lineBase : 0,
column: frame.range ? frame.range.start.character + this.columnBase : 0,
endLine: frame.range ? frame.range.end.line + this.lineBase : undefined,
endColumn: frame.range ? frame.range.end.character + this.columnBase : undefined,
presentationHint: frame.range ? "normal" : "subtle",
}));
const start = args.startFrame === undefined ? 0 : integer(args.startFrame);
const levels = args.levels === undefined ? 0 : integer(args.levels);
return {
body: {
stackFrames: frames.slice(start, levels === 0 ? undefined : start + levels),
totalFrames: frames.length,
},
};
}
case "scopes":
if (!this.frames.has(integer(args.frameId))) throw new Error("Stale or unknown frame");
return { body: { scopes: [] } };
case "pause":
this.thread(args);
await this.read();
this.check();
await this.client.system(this.actorRef, { kind: "pause" });
this.check();
this.stopKey = undefined;
return { after: () => this.invalidate() };
case "continue":
this.thread(args);
await this.read();
this.check();
await this.client.system(this.actorRef, { kind: "resume" });
this.check();
this.paused = false;
this.stopKey = undefined;
this.frames.clear();
return {
body: { allThreadsContinued: true },
after: () => {
this.event("continued", { threadId: 1, allThreadsContinued: true });
this.output("Actor resumed. Use Runa: Advance Scheduler to execute image ticks.");
},
};
case "next":
case "stepIn":
case "stepOut": {
this.thread(args);
if (
args.granularity !== undefined &&
args.granularity !== "statement" &&
args.granularity !== "line"
)
throw new Error("Only source-line stepping is supported");
const mode: StepMode =
request.command === "next" ? "over" : request.command === "stepOut" ? "out" : "into";
const inspection = await this.read();
this.check();
const reason = await this.client.stepSource(
this.actorRef,
inspection.implementation_id,
1000,
mode,
);
this.check();
this.frames.clear();
this.stopKey = undefined;
return {
after: () => {
this.event("continued", { threadId: 1, allThreadsContinued: true });
this.output(`Source step ${mode}: ${reason}.`);
this.invalidate();
},
};
}
default:
throw new Error(`Unsupported debug request: ${request.command}`);
}
}
private thread(args: Record<string, unknown>): void {
if (args.threadId !== 1) throw new Error("Unknown actor thread");
}
private async setBreakpoints(args: Record<string, unknown>): Promise<unknown> {
const source = record(args.source);
const requested = args.breakpoints === undefined ? [] : args.breakpoints;
if (!Array.isArray(requested)) throw new Error("Expected source breakpoints");
if (requested.length > 128) throw new Error("At most 128 breakpoints are supported");
const inspection = await this.read();
let matches = source.sourceReference === 1 || source.path === this.source?.path;
const knownWorking =
!!inspection.behavior_path &&
source.path === this.workingSourcePath(inspection.behavior_path);
if (!matches && knownWorking && inspection.behavior_path) {
// Only a byte-for-byte match can bind a mutable image file to loaded code.
const working = await this.client.readSource(inspection.behavior_path);
this.check();
matches =
working.source === inspection.source &&
this.visibleSourceMatches(inspection.behavior_path, inspection.source);
}
const entries = requested.map((entry: unknown) => {
const bp = record(entry);
const line = integer(bp.line, this.lineBase) - this.lineBase;
const unsupported =
bp.condition !== undefined || bp.hitCondition !== undefined || bp.logMessage !== undefined;
return { id: ++this.nextBreakpoint, line, unsupported, sourceMatches: matches };
});
if (!matches && !(knownWorking && this.breakpointGroups.has("working")))
return {
breakpoints: entries.map((entry) => ({
id: entry.id,
verified: false,
line: entry.line + this.lineBase,
source,
message: "Not this actor's exact loaded source; set breakpoints in its loaded source",
})),
};
const key =
source.sourceReference === 1 || source.path === this.source?.path ? "loaded" : "working";
const groups = new Map(this.breakpointGroups);
groups.set(key, { source, entries });
const lines = [
...new Set(
[...groups.values()].flatMap((group) =>
group.entries
.filter((entry) => entry.sourceMatches && !entry.unsupported)
.map((entry) => entry.line),
),
),
];
if (lines.length > 128)
throw new Error("At most 128 combined loaded/working breakpoint lines are supported");
const verified = await this.client.setBreakpoints(
this.actorRef,
inspection.implementation_id,
lines,
);
this.check();
for (const previous of this.breakpointGroups.get(key)?.entries ?? [])
this.breakpointStates.delete(previous.id);
this.breakpointGroups.set(key, { source, entries });
this.verifyBreakpoints(verified);
return { breakpoints: entries.map((entry) => this.breakpointResult(entry, source, verified)) };
}
private verifyBreakpoints(resolved: SourceBreakpoint[]): void {
for (const group of this.breakpointGroups.values())
for (const entry of group.entries) {
const breakpoint = this.breakpointResult(entry, group.source, resolved);
const previous = this.breakpointStates.get(entry.id);
this.breakpointStates.set(entry.id, breakpoint.verified);
if (previous !== undefined && previous !== breakpoint.verified)
this.event("breakpoint", { reason: "changed", breakpoint });
}
}
private breakpointResult(
entry: BreakpointEntry,
source: Record<string, unknown>,
resolved: SourceBreakpoint[],
) {
const breakpoint = resolved.find((bp) => bp.line === entry.line);
const verified =
entry.sourceMatches && !entry.unsupported && !!breakpoint?.instruction_pointers.length;
return {
id: entry.id,
verified,
line: entry.line + this.lineBase,
source,
message: !entry.sourceMatches
? "Not this actor's exact loaded source; set breakpoints in its loaded source"
: entry.unsupported
? "Conditions, hit counts and logpoints are not supported"
: verified
? undefined
: breakpoint
? "No executable line entry in the loaded source"
: "Actor breakpoint list changed outside this source document",
};
}
}

View File

@ -15,6 +15,7 @@ import { SnapshotDocuments } from "./snapshotProvider";
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
import { SourceDiagnostics } from "./diagnostics";
import { ExecutionDocuments, EXECUTION_SCHEME, frameSelection } from "./executionProvider";
import { ActorDebugAdapter } from "./debugger";
import { ActorFiles, ACTOR_SCHEME, ACTOR_FILES, actorFileUri } from "./actorFiles";
import {
ImageSourceFileSystem,
@ -125,6 +126,11 @@ export function activate(context: vscode.ExtensionContext): {
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
const revisions = new RevisionDocuments();
const executions = new ExecutionDocuments();
const debuggers = new Map<string, ActorDebugAdapter>();
const endDebuggers = () => {
for (const adapter of debuggers.values()) adapter.terminate("Image connection ended.");
debuggers.clear();
};
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
const output = vscode.window.createOutputChannel("Runa");
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
@ -147,6 +153,7 @@ export function activate(context: vscode.ExtensionContext): {
return closed;
};
closeConnections = async () => {
endDebuggers();
await closeClient(client);
await Promise.all(closing);
};
@ -173,6 +180,7 @@ export function activate(context: vscode.ExtensionContext): {
};
const disconnect = () => {
endDebuggers();
automaticRefresh?.dispose();
automaticRefresh = undefined;
const previous = client;
@ -198,6 +206,7 @@ export function activate(context: vscode.ExtensionContext): {
if (current !== client || currentSession !== session) return;
tree.session = session;
tree.replace(actors);
for (const adapter of debuggers.values()) adapter.invalidate();
actorFiles.refreshActors(session, actors);
sourceTree.refresh();
documents.refresh(session, endpoint, actors);
@ -246,6 +255,7 @@ export function activate(context: vscode.ExtensionContext): {
documents.disconnect(currentSession);
diagnostics.clear();
actorFiles.disconnect(currentSession);
endDebuggers();
sources.store.detach();
sourceTree.refresh();
connectionStatus(false);
@ -912,6 +922,97 @@ export function activate(context: vscode.ExtensionContext): {
command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected));
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider("runa", {
resolveDebugConfiguration: async (_folder, configuration) => {
try {
const connection = active();
if (
configuration.runaConnection !== undefined &&
configuration.runaConnection !== connection.session
)
throw new Error("Image connection changed before debugger attachment");
if (configuration.request && configuration.request !== "attach")
throw new Error("Runa supports attach debugging only");
const actorRef = configuration.actorRef ?? (await chooseActor());
if (!actorRef) return undefined;
connection.check();
return {
...configuration,
type: "runa",
request: "attach",
name: configuration.name ?? `Runa @${actorRef}`,
actorRef,
runaConnection: connection.session,
};
} catch (error) {
report(error);
return undefined;
}
},
}),
vscode.debug.registerDebugAdapterDescriptorFactory("runa", {
createDebugAdapterDescriptor: (debugSession) => {
const connection = active();
if (debugSession.configuration.runaConnection !== connection.session)
throw new Error("Image connection changed before debugger attachment");
const actorRef: unknown = debugSession.configuration.actorRef;
if (typeof actorRef !== "string") throw new Error("Choose a debug actor");
if ([...debuggers.values()].some((adapter) => adapter.actorRef === actorRef))
throw new Error("This actor already has a Runa debug session");
const messages = new vscode.EventEmitter<vscode.DebugProtocolMessage>();
const adapter = new ActorDebugAdapter(
connection.client,
actorRef,
(inspection) => executions.set(connection.session, inspection).toString(),
connection.check,
(message) => messages.fire(message),
(path) => sourceFileUri(connection.endpoint, path).toString(),
(path, source) => {
const uri = sourceFileUri(connection.endpoint, path).toString();
const document = vscode.workspace.textDocuments.find(
(document) => document.uri.toString() === uri,
);
return !document || document.getText() === source;
},
);
debuggers.set(debugSession.id, adapter);
return new vscode.DebugAdapterInlineImplementation({
onDidSendMessage: messages.event,
handleMessage: (message) => adapter.handleMessage(message),
dispose: () => {
debuggers.delete(debugSession.id);
adapter.dispose();
messages.dispose();
},
});
},
}),
command("runa.debugActor", async (selected?: { actor_ref: string }) => {
const connection = active();
const actorRef = await chooseActor(selected);
if (!actorRef) return;
connection.check();
const inspection = await connection.client.inspectExecution(actorRef);
connection.check();
const started = await vscode.debug.startDebugging(
undefined,
{
type: "runa",
request: "attach",
name: `Runa @${actorRef}`,
actorRef,
runaConnection: connection.session,
},
{ suppressSaveBeforeStart: true },
);
connection.check();
if (started) {
await vscode.window.showTextDocument(
await vscode.workspace.openTextDocument(executions.set(connection.session, inspection)),
);
}
return started;
}),
tree,
sources,
sourceTree,

View File

@ -0,0 +1,50 @@
import { ActorDebugAdapter, DebugMessage } from "../src/debugger";
export class DebugHarness {
readonly adapter: ActorDebugAdapter;
readonly messages: DebugMessage[] = [];
private seq = 0;
private readonly listeners = new Set<() => void>();
constructor(create: (send: (message: DebugMessage) => void) => ActorDebugAdapter) {
this.adapter = create((message) => {
this.messages.push(message);
for (const listener of this.listeners) listener();
});
}
async request<T = unknown>(command: string, args?: Record<string, unknown>): Promise<T> {
const seq = ++this.seq;
const response = this.wait((message) => message.request_seq === seq);
this.adapter.handleMessage({ seq, type: "request", command, arguments: args });
const message = await response;
if (!message.success) throw new Error(message.message);
return message.body as T;
}
wait(predicate: (message: DebugMessage) => boolean, since = 0): Promise<DebugMessage> {
return new Promise((resolve, reject) => {
const check = () => {
const found = this.messages.slice(since).find(predicate);
if (!found) return;
clearTimeout(timer);
this.listeners.delete(check);
resolve(found);
};
const timer = setTimeout(() => {
this.listeners.delete(check);
reject(new Error("Timed out waiting for DAP message"));
}, 5000);
this.listeners.add(check);
check();
});
}
async attach(): Promise<void> {
await this.request("initialize");
await this.request("attach");
await this.wait((message) => message.event === "initialized");
await this.request("configurationDone");
await this.wait((message) => message.event === "stopped");
}
}

View File

@ -0,0 +1,344 @@
import assert from "node:assert/strict";
import { test, TestContext } from "node:test";
import { ActorDebugAdapter } from "../src/debugger";
import { DebugHarness } from "./debugHarness";
import { actor, REF } from "./fixtures";
import { DebugStopReason, ExecutionInspection, SourceBreakpoint, StepMode } from "../src/protocol";
function fixture(t: TestContext) {
const snapshot = actor();
snapshot.status = { status: "running" };
const inspection: ExecutionInspection = {
actor_ref: REF,
incarnation: 1n,
implementation_id: snapshot.implementation_id,
source_id: "source:test",
source: "pub fn handle(ctx, state, sender, message) {\n state + 1\n}\n",
behavior_path: "actors/test",
execution: {
vm_steps: 20n,
awaiting: false,
call_depth: 1n,
frames_truncated: false,
frames: [
{
kind: "current",
instruction_pointer: 12n,
source_instruction_pointer: 12n,
function: "handle",
range: { start: { line: 1, character: 4 }, end: { line: 1, character: 9 } },
},
],
},
debugger: { breakpoints: [], stop_reason: null },
};
let present = true;
let connected = true;
let visibleMatches = true;
let working = inspection.source;
const modes: StepMode[] = [];
let stepReason: DebugStopReason = "step";
let mutations = 0;
const harness = new DebugHarness(
(send) =>
new ActorDebugAdapter(
{
inspectActor: async () => snapshot,
inspectExecution: async () => structuredClone(inspection),
listActors: async () => (present ? [snapshot] : []),
system: async (_actor, message) => {
mutations++;
if (message.kind === "pause") snapshot.status = { status: "paused" };
else if (message.kind === "resume") snapshot.status = { status: "running" };
},
readSource: async () => ({ source: working, path: "actors/test", source_id: "working" }),
setBreakpoints: async (_actor, _implementation, lines) => {
mutations++;
const breakpoints = lines.map((line) => ({
line,
instruction_pointers: line === 1 ? [12n] : [],
}));
inspection.debugger = { breakpoints, stop_reason: null };
return breakpoints;
},
stepSource: async (_actor, _implementation, budget, mode = "into") => {
assert.equal(budget, 1000);
mutations++;
modes.push(mode);
inspection.execution!.vm_steps++;
inspection.debugger!.stop_reason = stepReason;
if (stepReason === "awaiting") inspection.execution!.awaiting = true;
if (stepReason === "completed" || stepReason === "failed") {
inspection.execution = null;
snapshot.execution = null;
}
if (stepReason === "failed") snapshot.status = { status: "failed", message: "boom" };
if (stepReason === "removed") present = false;
return stepReason;
},
},
REF,
() => "runa-execution://1/loaded/source.rn",
() => {
if (!connected) throw new Error("Connection changed");
},
send,
(path) => `runa-image://127.0.0.1:3737/source/${path}.rn`,
() => visibleMatches,
),
);
t.after(() => harness.adapter.dispose());
return {
harness,
snapshot,
inspection,
modes,
mutations: () => mutations,
reason: (reason: DebugStopReason) => {
stepReason = reason;
},
remove: () => {
present = false;
},
disconnect: () => {
connected = false;
},
edit: (source: string) => {
working = source;
},
unsave: () => {
visibleMatches = false;
},
};
}
test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes and bounded modes", async (t) => {
const f = fixture(t);
const h = f.harness;
await h.attach();
assert.equal(f.snapshot.status.status, "paused");
assert.equal(f.mutations(), 1);
const stack = await h.request<{
stackFrames: {
id: number;
line: number;
column: number;
source: { sourceReference: number };
}[];
totalFrames: number;
}>("stackTrace", { threadId: 1 });
assert.equal(stack.totalFrames, 1);
const frame = stack.stackFrames[0]!;
assert.equal(frame.line, 2);
assert.equal(frame.column, 5);
assert.deepEqual(await h.request("scopes", { frameId: frame.id }), { scopes: [] });
assert.deepEqual(await h.request("source", { sourceReference: frame.source.sourceReference }), {
content: f.inspection.source,
mimeType: "text/x-rune",
});
for (const command of ["stepIn", "next", "stepOut"]) {
const since = h.messages.length;
await h.request(command, { threadId: 1 });
await h.wait((message) => message.event === "stopped", since);
}
assert.deepEqual(f.modes, ["into", "over", "out"]);
await assert.rejects(h.request("scopes", { frameId: frame.id }), /Stale/);
await assert.rejects(h.request("evaluate", { expression: "state" }), /Unsupported/);
await assert.rejects(h.request("next", { threadId: 2 }), /thread/);
await assert.rejects(
h.request("stepIn", { threadId: 1, granularity: "instruction" }),
/source-line/,
);
await h.request("continue", { threadId: 1 });
assert.equal(f.snapshot.status.status, "running");
assert.equal(f.mutations(), 5);
const mutations = f.mutations();
await h.request("disconnect", { terminateDebuggee: true });
assert.equal(f.snapshot.status.status, "running");
assert.equal(f.mutations(), mutations, "disconnect must not kill/resume/clear breakpoints");
assert.ok(h.messages.some((message) => message.event === "terminated"));
});
test("DAP gutter breakpoints require exact loaded sources and explicitly reject unsupported forms", async (t) => {
const f = fixture(t);
const h = f.harness;
await h.attach();
type Result = { breakpoints: { verified: boolean; line: number; message?: string }[] };
const set = (path: string, lines = [2, 3]) =>
h.request<Result>("setBreakpoints", {
source: { path },
breakpoints: lines.map((line) => ({ line })),
});
const path = "runa-image://127.0.0.1:3737/source/actors/test.rn";
assert.deepEqual(
(await set(path)).breakpoints.map((bp) => bp.verified),
[true, false],
);
const before = f.mutations();
f.edit("newer image source");
assert.equal((await set(path, [2])).breakpoints[0]?.verified, false);
assert.equal(
f.mutations(),
before + 1,
"a changed working document removes only its previous breakpoint group",
);
assert.deepEqual(f.inspection.debugger?.breakpoints, []);
assert.equal(
(await set("runa-image://127.0.0.1:9999/source/actors/test.rn")).breakpoints[0]?.verified,
false,
);
assert.equal(f.mutations(), before + 1, "unrelated files cannot replace actor breakpoints");
f.edit(f.inspection.source);
f.unsave();
assert.equal((await set(path, [2])).breakpoints[0]?.verified, false);
assert.equal(f.mutations(), before + 2);
const conditional = await h.request<Result>("setBreakpoints", {
source: { sourceReference: 1 },
breakpoints: [{ line: 2, condition: "true" }],
});
assert.match(conditional.breakpoints[0]!.message!, /not supported/);
assert.equal(conditional.breakpoints[0]?.verified, false);
assert.deepEqual(f.inspection.debugger?.breakpoints, []);
await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [{ line: 2 }] });
assert.deepEqual(f.inspection.debugger?.breakpoints, [
{ line: 1, instruction_pointers: [12n] },
] satisfies SourceBreakpoint[]);
await set(path, []);
assert.deepEqual(
f.inspection.debugger?.breakpoints,
[{ line: 1, instruction_pointers: [12n] }],
"clearing an alias must not clear loaded-document breakpoints",
);
const since = h.messages.length;
f.inspection.debugger!.breakpoints = [];
h.adapter.invalidate();
const changed = await h.wait((message) => message.event === "breakpoint", since);
assert.equal((changed.body as { breakpoint: { verified: boolean } }).breakpoint.verified, false);
await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: [] });
assert.deepEqual(f.inspection.debugger?.breakpoints, []);
});
test("DAP reports external pauses/failures/removal and terminates on version or connection changes", async (t) => {
for (const change of [
"failure",
"remove",
"implementation",
"incarnation",
"connection",
] as const) {
const f = fixture(t);
const h = f.harness;
await h.attach();
const since = h.messages.length;
if (change === "failure") f.snapshot.status = { status: "failed", message: "boom" };
if (change === "remove") f.remove();
if (change === "implementation") f.inspection.implementation_id = "replacement";
if (change === "incarnation") f.inspection.incarnation++;
if (change === "connection") f.disconnect();
h.adapter.invalidate();
const event = await h.wait(
(message) => message.event === (change === "failure" ? "stopped" : "terminated"),
since,
);
if (change === "failure") assert.equal((event.body as { reason: string }).reason, "exception");
}
});
test("DAP exposes bounded budget/await/completion/failure/removal outcomes without retrying", async (t) => {
for (const reason of ["budget", "awaiting", "completed", "failed", "removed"] as const) {
const f = fixture(t);
const h = f.harness;
await h.attach();
f.reason(reason);
const since = h.messages.length;
await h.request("next", { threadId: 1 });
const event = await h.wait(
(message) => message.event === (reason === "removed" ? "terminated" : "stopped"),
since,
);
assert.equal(f.modes.length, 1);
assert.ok(
h.messages.some(
(message) =>
message.event === "output" &&
JSON.stringify(message.body).includes(`Source step over: ${reason}`),
),
);
if (reason === "failed") assert.equal((event.body as { reason: string }).reason, "exception");
if (reason === "completed")
assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
}
});
test("DAP enforces the combined breakpoint limit and rejects malformed requests without mutation", async (t) => {
const f = fixture(t);
const h = f.harness;
await h.attach();
const breakpoints = Array.from({ length: 128 }, (_, line) => ({ line: line + 2 }));
await h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints });
const mutations = f.mutations();
await assert.rejects(
h.request("setBreakpoints", {
source: { path: "runa-image://127.0.0.1:3737/source/actors/test.rn" },
breakpoints: [{ line: 130 }],
}),
/128 combined/,
);
await assert.rejects(
h.request("setBreakpoints", {
source: { sourceReference: 1 },
breakpoints: [...breakpoints, { line: 130 }],
}),
/128 breakpoints/,
);
await assert.rejects(
h.request("setBreakpoints", {
source: { sourceReference: 1 },
breakpoints: [{ line: 0 }],
}),
/integer/,
);
await assert.rejects(
h.request("setBreakpoints", { source: { sourceReference: 1 }, breakpoints: null }),
/Expected source breakpoints/,
);
await assert.rejects(h.request("source", { sourceReference: 2 }), /Unknown loaded source/);
assert.equal(f.mutations(), mutations);
assert.equal(f.inspection.debugger?.breakpoints.length, 128);
});
test("DAP honors zero-based client coordinates, paging, unknown locations and failed attach", async (t) => {
const f = fixture(t);
const h = f.harness;
await h.request("initialize", { linesStartAt1: false, columnsStartAt1: false });
await h.request("attach");
await h.request("configurationDone");
await h.wait((message) => message.event === "stopped");
const result = await h.request<{ stackFrames: { line: number; column: number }[] }>(
"stackTrace",
{ threadId: 1, startFrame: 0, levels: 1 },
);
assert.equal(result.stackFrames[0]?.line, 1);
assert.equal(result.stackFrames[0]?.column, 4);
assert.deepEqual(await h.request("stackTrace", { threadId: 1, startFrame: 1 }), {
stackFrames: [],
totalFrames: 1,
});
f.inspection.execution!.frames[0]!.range = null;
const since = h.messages.length;
await h.request("next", { threadId: 1 });
await h.wait((message) => message.event === "stopped", since);
const unmapped = await h.request<{ stackFrames: { line: number; source?: unknown }[] }>(
"stackTrace",
{ threadId: 1 },
);
assert.equal(unmapped.stackFrames[0]?.line, 0);
assert.equal(unmapped.stackFrames[0]?.source, undefined);
const failed = fixture(t);
failed.snapshot.status = { status: "failed", message: "boom" };
await assert.rejects(failed.harness.request("attach"), /nonfailed/);
assert.equal(failed.mutations(), 0);
});