feat: add failed actor debugger inspection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 23:40:04 +01:00
parent 4f47a1b3de
commit 191d0d6939
8 changed files with 323 additions and 17 deletions

View File

@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Native VS Code debugger attachment to failed actors for retained-state,
reserved-message and failure inspection, plus DAP exception details for both
existing and newly observed failures. Debug controls never retry failed actors;
no post-mortem stack is fabricated.
- Read-only snapshot-path watches and Debug Console lookups using - Read-only snapshot-path watches and Debug Console lookups using
`actor/<JSON Pointer path>`, with Add to Watch paths on actor variables, `actor/<JSON Pointer path>`, with Add to Watch paths on actor variables,
expandable results, exact integers and explicit rejection of Rune expressions. expandable results, exact integers and explicit rejection of Rune expressions.

View File

@ -41,6 +41,8 @@ a read-only committed actor snapshot with expandable state and mailbox.
Watch and Debug Console accept snapshot paths such as `actor/committed_state`; Watch and Debug Console accept snapshot paths such as `actor/committed_state`;
they never execute actor code. Frame locals, Rune expression evaluation, they never execute actor code. Frame locals, Rune expression evaluation,
and persistent storage remain unimplemented. and persistent storage remain unimplemented.
Failed actors can also be attached for exception details and retained-snapshot
inspection without restarting them; post-mortem stacks are not retained.
## Design goals ## Design goals

View File

@ -554,6 +554,11 @@ Console lookups resolve only owned committed data and reuse the same expandable
values. They do not execute Rune or access live VM slots. Rune 0.14.2 lacks values. They do not execute Rune or access live VM slots. Rune 0.14.2 lacks
local-name/lifetime-to-slot debug metadata; frame locals/Rune expression local-name/lifetime-to-slot debug metadata; frame locals/Rune expression
evaluation and conditional stops remain planned. evaluation and conditional stops remain planned.
Failed actors can be attached without lifecycle mutation. DAP exception details
report the sampled actor failure and snapshot watches expose retained committed
state and the reserved message, not a post-mortem stack. Debug run controls reject
failed actors; explicit repair/restart remains separate, and restart ends the
old-incarnation debug session.
Non-destructive image-change notifications and automatic snapshot refresh are Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport. implemented over the shared `runa-debug` WebSocket transport.

View File

@ -218,7 +218,8 @@ port. Each session represents one actor/thread and is bound to the current
connection, installed implementation, and incarnation. Only one session per connection, installed implementation, and incarnation. Only one session per
actor is allowed. Attach pauses a live nonfailed actor, retaining its active 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 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. starts a handler. An already-failed actor can also be attached without changing
its status, incarnation, state or mailbox.
- Set gutter breakpoints in the actor's immutable loaded source, opened by the - 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, command or Call Stack view. The debugger serves the exact compiled text,
@ -244,7 +245,10 @@ starts a handler. Failed actors must be repaired/restarted before attachment.
last-safe-await snapshots are identified in the stop description. Unmapped last-safe-await snapshots are identified in the stop description. Unmapped
frames have no invented source location. At handler completion the stack is frames have no invented source location. At handler completion the stack is
empty, state commits normally, and the actor remains paused. A failure shows empty, state commits normally, and the actor remains paused. A failure shows
an exception stop with its error text, not a post-mortem stack. an exception stop with its error text, not a post-mortem stack. The adapter's
DAP `exceptionInfo` response supplies the sampled failure text with the
`runa.actorFailure` identifier and `unhandled` break mode. No exception class,
failed instruction location or stack trace is inferred from that text.
- Variables shows an **Actor snapshot (committed; not frame locals)** scope for - Variables shows an **Actor snapshot (committed; not frame locals)** scope for
each active frame. All frames share that actor snapshot, with expandable each active frame. All frames share that actor snapshot, with expandable
`committed_state`, `mailbox` (`reserved` as a zero/one-element list and `committed_state`, `mailbox` (`reserved` as a zero/one-element list and
@ -262,6 +266,18 @@ starts a handler. Failed actors must be repaired/restarted before attachment.
refreshes the stop and scope rather than changing data behind existing handles. refreshes the stop and scope rather than changing data behind existing handles.
Handler boundaries and post-mortem failures have no frames, hence no DAP scopes; Handler boundaries and post-mortem failures have no frames, hence no DAP scopes;
use the actor files there. use the actor files there.
- **Inspecting failures:** choose **Debug Actor** on a failed actor, or leave
the debugger attached when an invocation fails. The session reports an
exception stop and writes a failure/recovery explanation to the Debug Console.
Use `actor/metadata/failure`, `actor/committed_state` and
`actor/mailbox/reserved` in Watch or Debug Console to inspect retained data,
even though Call Stack is empty. Continue, Pause and all stepping controls
reject failed actors explicitly; they never retry, resume, or restart them.
State/mailbox repair, temporary-behavior edits and **Restart Actor** remain
separate explicit actions. Repair-only changes refresh the sampled data while
preserving failure; a new implementation or incarnation ends the session.
Detaching leaves the failed actor untouched. Exception filters and
first-chance/pre-unwind pauses are not supported.
- Watch and Debug Console support **snapshot paths**, not Rune expressions: - Watch and Debug Console support **snapshot paths**, not Rune expressions:
- `actor` expands the whole committed snapshot. - `actor` expands the whole committed snapshot.
- `actor/committed_state` reads the last committed state. - `actor/committed_state` reads the last committed state.

View File

@ -16,6 +16,7 @@ test("debugger variables show committed data rather than invocation drafts and n
await client.close(); await client.close();
await image.stop(); await image.stop();
}); });
const source = const source =
'pub fn handle(ctx, state, sender, message) {\n state.items.push(message);\n nested(state)\n}\nfn nested(state) {\n if state.other.flag { state } else { panic("unexpected flag") }\n}'; 'pub fn handle(ctx, state, sender, message) {\n state.items.push(message);\n nested(state)\n}\nfn nested(state) {\n if state.other.flag { state } else { panic("unexpected flag") }\n}';
const file = await client.writeSource("tests/variables", null, source); const file = await client.writeSource("tests/variables", null, source);
@ -144,3 +145,109 @@ test("debugger variables show committed data rather than invocation drafts and n
/Stale/, /Stale/,
); );
}); });
test("failed actor debug attachment exposes retained data and exception details without retrying", async (t) => {
const image = await startImage();
const client = await ImageClient.connect(image.address, (error) =>
assert.equal(error, undefined),
);
t.after(async () => {
await client.close();
await image.stop();
});
const source =
'pub fn handle(ctx, state, sender, message) {\n state.items.push(9);\n panic("failure inspection probe")\n}';
const file = await client.writeSource("tests/failed_debug", null, source);
for (const failBeforeAttach of [true, false]) {
const actor = await client.spawn(file.path, parseValue('{"items":[1]}'));
await client.send(actor, parseValue('{"op":"fail","value":-9223372036854775808}'));
if (failBeforeAttach) await client.tick(1, 1000);
const h = new DebugHarness(
(send) =>
new ActorDebugAdapter(
client,
actor,
() => "runa-execution://1/loaded/failure.rn",
() => {},
send,
(path) => `runa-image://${image.address}/source/${path}.rn`,
() => true,
),
);
t.after(() => h.adapter.dispose());
await h.attach();
if (!failBeforeAttach) {
await h.request("continue", { threadId: 1 });
await client.tick(1, 1000);
h.adapter.invalidate();
await h.wait(
(message) =>
message.event === "stopped" &&
(message.body as { reason: string }).reason === "exception",
);
}
const before = await client.inspectActor(actor);
assert.equal(before.status.status, "failed");
const details = await h.request<{
description: string;
breakMode: string;
details: { message: string; stackTrace?: string };
}>("exceptionInfo", { threadId: 1 });
assert.equal(details.description, before.status.message);
assert.match(details.description, /failure inspection probe/);
assert.equal(details.breakMode, "unhandled");
assert.equal(details.details.stackTrace, undefined);
assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
assert.equal(
(
await h.request<{ result: string }>("evaluate", {
expression: "actor/committed_state/items/0",
context: "watch",
})
).result,
"1",
);
assert.equal(
(
await h.request<{ result: string }>("evaluate", {
expression: "actor/mailbox/reserved/0/value",
context: "repl",
})
).result,
"-9223372036854775808",
);
for (const command of ["continue", "next", "stepIn", "stepOut", "pause"])
await assert.rejects(h.request(command, { threadId: 1 }), /repair\/restart explicitly/);
assert.deepEqual(await client.inspectActor(actor), before);
if (failBeforeAttach) {
await h.request("disconnect");
assert.deepEqual(await client.inspectActor(actor), before);
} else {
const since = h.messages.length;
await client.editActor(actor, parseValue('{"items":[2]}'), null);
h.adapter.invalidate();
await h.wait((message) => message.event === "stopped", since);
assert.equal(
(
await h.request<{ result: string }>("evaluate", {
expression: "actor/committed_state/items/0",
context: "watch",
})
).result,
"2",
);
assert.equal(
(await h.request<{ description: string }>("exceptionInfo", { threadId: 1 })).description,
before.status.message,
);
await client.system(actor, { kind: "restart" });
h.adapter.invalidate();
await h.wait((message) => message.event === "terminated");
assert.equal((await client.inspectActor(actor)).incarnation, before.incarnation + 1n);
}
await client.system(actor, { kind: "kill" });
}
});

View File

@ -411,14 +411,18 @@ async function sourceDebugging(address: string): Promise<void> {
async function nativeDebugging(address: string): Promise<void> { async function nativeDebugging(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
const messages: DebugMessage[] = []; const messages: DebugMessage[] = [];
const sessions = new Map<string, vscode.DebugSession>();
const changes = new vscode.EventEmitter<void>(); const changes = new vscode.EventEmitter<void>();
const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", { const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", {
createDebugAdapterTracker: () => ({ createDebugAdapterTracker: (debugSession) => {
onDidSendMessage: (message: DebugMessage) => { sessions.set(debugSession.configuration.actorRef, debugSession);
messages.push(message); return {
changes.fire(); onDidSendMessage: (message: DebugMessage) => {
}, messages.push(message);
}), changes.fire();
},
};
},
}); });
const wait = (predicate: (message: DebugMessage) => boolean, since = 0) => const wait = (predicate: (message: DebugMessage) => boolean, since = 0) =>
new Promise<DebugMessage>((resolve, reject) => { new Promise<DebugMessage>((resolve, reject) => {
@ -461,6 +465,7 @@ async function nativeDebugging(address: string): Promise<void> {
}); });
let breakpoint: vscode.SourceBreakpoint | undefined; let breakpoint: vscode.SourceBreakpoint | undefined;
let session: vscode.DebugSession | undefined; let session: vscode.DebugSession | undefined;
let phase = "normal actor controls";
try { try {
const source = 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}"; "pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}";
@ -624,8 +629,71 @@ async function nativeDebugging(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.connect", address); await vscode.commands.executeCommand("runa.connect", address);
await client.system(actor, { kind: "kill" }); await client.system(actor, { kind: "kill" });
await client.system(other, { kind: "kill" }); await client.system(other, { kind: "kill" });
const failingSource = await client.writeSource(
"tests/native_failure",
null,
'pub fn handle(ctx, state, sender, message) {\n panic("native failure probe")\n}',
);
const failedActor = await client.spawn(failingSource.path, parseValue("42"));
await client.send(failedActor, parseValue('{"op":"fail"}'));
await client.tick(1, 1000);
const failureBefore = await client.inspectActor(failedActor);
assert.equal(failureBefore.status.status, "failed");
since = messages.length;
phase = "failed actor attachment";
assert.equal(
await vscode.commands.executeCommand("runa.debugActor", { actor_ref: failedActor }),
true,
);
const failureStop = await wait((message) => message.event === "stopped", since);
assert.equal((failureStop.body as { reason: string }).reason, "exception");
session = sessions.get(failedActor);
assert.ok(session);
phase = "failed actor exception details";
const exception: { description: string; details: { message: string; stackTrace?: string } } =
await session.customRequest("exceptionInfo", { threadId: 1 });
assert.equal(exception.description, failureBefore.status.message);
assert.match(exception.details.message, /native failure probe/);
assert.equal(exception.details.stackTrace, undefined);
phase = "failed actor retained snapshot";
assert.deepEqual(await session.customRequest("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
assert.equal(
(
await session.customRequest("evaluate", {
expression: "actor/committed_state",
context: "watch",
})
).result,
"42",
);
assert.equal(
(
await session.customRequest("evaluate", {
expression: "actor/mailbox/reserved/0/op",
context: "repl",
})
).result,
'"fail"',
);
await assert.rejects(
Promise.resolve(session.customRequest("continue", { threadId: 1 })),
/repair\/restart explicitly/,
);
since = messages.length;
await vscode.debug.stopDebugging(session);
await wait((message) => message.event === "terminated", since);
assert.deepEqual(await client.inspectActor(failedActor), failureBefore);
await client.system(failedActor, { kind: "kill" });
console.log( console.log(
"Runa native debugger: attach, gutter breakpoint, real toolbar stepping, exact sources, isolation and detach passed", "Runa native debugger: attach, gutter breakpoint, real toolbar stepping, exact sources, failure inspection, isolation and detach passed",
);
} catch (error) {
throw new Error(
`Native debugger test failed during ${phase}: ${String(error)}; last DAP messages: ${JSON.stringify(messages.slice(-8))}`,
{ cause: error },
); );
} finally { } finally {
if (breakpoint) vscode.debug.removeBreakpoints([breakpoint]); if (breakpoint) vscode.debug.removeBreakpoints([breakpoint]);

View File

@ -269,6 +269,10 @@ export class ActorDebugAdapter {
threadId: 1, threadId: 1,
allThreadsStopped: true, allThreadsStopped: true,
}); });
if (actor.status.status === "failed")
this.output(
`Actor failed: ${actor.status.message ?? "failure details unavailable"}. No post-mortem stack is retained. Inspect actor/committed_state and actor/mailbox/reserved; repair/restart is explicit.`,
);
} }
private async dispatch(request: Request): Promise<{ body?: unknown; after?: () => void }> { private async dispatch(request: Request): Promise<{ body?: unknown; after?: () => void }> {
@ -280,6 +284,7 @@ export class ActorDebugAdapter {
body: { body: {
supportsConfigurationDoneRequest: true, supportsConfigurationDoneRequest: true,
supportsLoadedSourcesRequest: true, supportsLoadedSourcesRequest: true,
supportsExceptionInfoRequest: true,
supportsTerminateDebuggee: false, supportsTerminateDebuggee: false,
supportSuspendDebuggee: false, supportSuspendDebuggee: false,
}, },
@ -291,10 +296,9 @@ export class ActorDebugAdapter {
this.check(); this.check();
const actor = await this.client.inspectActor(this.actorRef); const actor = await this.client.inspectActor(this.actorRef);
this.check(); this.check();
if (actor.status.status === "failed" || actor.status.status === "stopped") if (actor.status.status === "stopped") throw new Error("Cannot attach to a stopped actor");
throw new Error("Attach requires a live, nonfailed actor");
await this.read(); await this.read();
if (actor.status.status !== "paused") { if (actor.status.status !== "paused" && actor.status.status !== "failed") {
this.check(); this.check();
await this.client.system(this.actorRef, { kind: "pause" }); await this.client.system(this.actorRef, { kind: "pause" });
this.check(); this.check();
@ -317,6 +321,23 @@ export class ActorDebugAdapter {
return { after: () => this.invalidate() }; return { after: () => this.invalidate() };
case "threads": case "threads":
return { body: { threads: [{ id: 1, name: `@${this.actorRef}` }] } }; return { body: { threads: [{ id: 1, name: `@${this.actorRef}` }] } };
case "exceptionInfo": {
this.thread(args);
if (this.actorSnapshot?.status.status !== "failed")
throw new Error("No sampled actor failure at the current stop");
const message = this.actorSnapshot.status.message;
return {
body: {
exceptionId: "runa.actorFailure",
description: message ?? "Actor failed; failure details unavailable",
breakMode: "unhandled",
details: {
typeName: "Runa actor failure",
...(message === undefined ? {} : { message }),
},
},
};
}
case "loadedSources": case "loadedSources":
return { body: { sources: this.source ? [this.source] : [] } }; return { body: { sources: this.source ? [this.source] : [] } };
case "source": { case "source": {
@ -390,6 +411,7 @@ export class ActorDebugAdapter {
} }
case "pause": case "pause":
this.thread(args); this.thread(args);
this.requireNonfailed();
await this.read(); await this.read();
this.check(); this.check();
await this.client.system(this.actorRef, { kind: "pause" }); await this.client.system(this.actorRef, { kind: "pause" });
@ -398,6 +420,7 @@ export class ActorDebugAdapter {
return { after: () => this.invalidate() }; return { after: () => this.invalidate() };
case "continue": case "continue":
this.thread(args); this.thread(args);
this.requireNonfailed();
await this.read(); await this.read();
this.check(); this.check();
await this.client.system(this.actorRef, { kind: "resume" }); await this.client.system(this.actorRef, { kind: "resume" });
@ -418,6 +441,7 @@ export class ActorDebugAdapter {
case "stepIn": case "stepIn":
case "stepOut": { case "stepOut": {
this.thread(args); this.thread(args);
this.requireNonfailed();
if ( if (
args.granularity !== undefined && args.granularity !== undefined &&
args.granularity !== "statement" && args.granularity !== "statement" &&
@ -456,6 +480,13 @@ export class ActorDebugAdapter {
if (args.threadId !== 1) throw new Error("Unknown actor thread"); if (args.threadId !== 1) throw new Error("Unknown actor thread");
} }
private requireNonfailed(): void {
if (this.actorSnapshot?.status.status === "failed")
throw new Error(
"Actor failed; repair/restart explicitly before running. Debug controls never retry a failed invocation.",
);
}
private async setBreakpoints(args: Record<string, unknown>): Promise<unknown> { private async setBreakpoints(args: Record<string, unknown>): Promise<unknown> {
const source = record(args.source); const source = record(args.source);
const requested = args.breakpoints === undefined ? [] : args.breakpoints; const requested = args.breakpoints === undefined ? [] : args.breakpoints;

View File

@ -414,7 +414,7 @@ test("DAP enforces the combined breakpoint limit and rejects malformed requests
assert.equal(f.inspection.debugger?.breakpoints.length, 128); assert.equal(f.inspection.debugger?.breakpoints.length, 128);
}); });
test("DAP honors zero-based client coordinates, paging, unknown locations and failed attach", async (t) => { test("DAP honors zero-based client coordinates, paging, unknown locations and stopped attach", async (t) => {
const f = fixture(t); const f = fixture(t);
const h = f.harness; const h = f.harness;
await h.request("initialize", { linesStartAt1: false, columnsStartAt1: false }); await h.request("initialize", { linesStartAt1: false, columnsStartAt1: false });
@ -441,8 +441,80 @@ test("DAP honors zero-based client coordinates, paging, unknown locations and fa
); );
assert.equal(unmapped.stackFrames[0]?.line, 0); assert.equal(unmapped.stackFrames[0]?.line, 0);
assert.equal(unmapped.stackFrames[0]?.source, undefined); assert.equal(unmapped.stackFrames[0]?.source, undefined);
const failed = fixture(t); const stopped = fixture(t);
failed.snapshot.status = { status: "failed", message: "boom" }; stopped.snapshot.status = { status: "stopped" };
await assert.rejects(failed.harness.request("attach"), /nonfailed/); await assert.rejects(stopped.harness.request("attach"), /stopped/);
assert.equal(failed.mutations(), 0); assert.equal(stopped.mutations(), 0);
});
test("DAP attaches to failed actors without lifecycle mutations or fabricated frames", async (t) => {
const f = fixture(t);
f.snapshot.status = { status: "failed", message: 'Missing index "value"\nhandler failed' };
f.snapshot.execution = null;
f.inspection.execution = null;
const h = f.harness;
const capabilities = await h.request<{ supportsExceptionInfoRequest: boolean }>("initialize");
assert.equal(capabilities.supportsExceptionInfoRequest, true);
await h.request("attach");
await h.request("configurationDone");
const stop = await h.wait((message) => message.event === "stopped");
assert.equal((stop.body as { reason: string }).reason, "exception");
const details = await h.request("exceptionInfo", { threadId: 1 });
assert.deepEqual(details, {
exceptionId: "runa.actorFailure",
description: f.snapshot.status.message,
breakMode: "unhandled",
details: { typeName: "Runa actor failure", message: f.snapshot.status.message },
});
assert.deepEqual(await h.request("stackTrace", { threadId: 1 }), {
stackFrames: [],
totalFrames: 0,
});
assert.equal(
(
await h.request<{ result: string }>("evaluate", {
expression: "actor/metadata/failure",
context: "repl",
})
).result,
JSON.stringify(f.snapshot.status.message),
);
assert.equal(
(
await h.request<{ result: string }>("evaluate", {
expression: "actor/mailbox/reserved/0",
context: "watch",
})
).result,
"null",
);
for (const command of ["continue", "pause", "next", "stepIn", "stepOut"])
await assert.rejects(h.request(command, { threadId: 1 }), /repair\/restart explicitly/);
await assert.rejects(h.request("exceptionInfo", { threadId: 2 }), /thread/);
assert.equal(f.mutations(), 0);
await h.request("disconnect");
assert.equal(f.mutations(), 0);
assert.equal(f.snapshot.status.status, "failed");
});
test("DAP exception details track new failures, reject nonfailed stops and identify missing detail", async (t) => {
const f = fixture(t);
const h = f.harness;
await h.attach();
await assert.rejects(h.request("exceptionInfo", { threadId: 1 }), /No sampled actor failure/);
for (const message of ["division failed", undefined]) {
const since = h.messages.length;
f.snapshot.status = { status: "failed", ...(message === undefined ? {} : { message }) };
f.snapshot.execution = null;
f.inspection.execution = null;
h.adapter.invalidate();
await h.wait((event) => event.event === "stopped", since);
const reply = await h.request<{
description: string;
details: { message?: string; stackTrace?: string };
}>("exceptionInfo", { threadId: 1 });
assert.equal(reply.description, message ?? "Actor failed; failure details unavailable");
assert.equal(reply.details.message, message);
assert.equal(reply.details.stackTrace, undefined);
}
}); });