diff --git a/CHANGELOG.md b/CHANGELOG.md index db1a4a1..b4cf082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file. ### 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 `actor/`, with Add to Watch paths on actor variables, expandable results, exact integers and explicit rejection of Rune expressions. diff --git a/README.md b/README.md index 89867e9..3302bcc 100644 --- a/README.md +++ b/README.md @@ -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`; they never execute actor code. Frame locals, Rune expression evaluation, 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 diff --git a/docs/architecture.md b/docs/architecture.md index 30be0eb..526baf8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 local-name/lifetime-to-slot debug metadata; frame locals/Rune expression 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 implemented over the shared `runa-debug` WebSocket transport. diff --git a/vscode/README.md b/vscode/README.md index 5b01c8c..4d07146 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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 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. +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 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 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. + 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 each active frame. All frames share that actor snapshot, with expandable `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. Handler boundaries and post-mortem failures have no frames, hence no DAP scopes; 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: - `actor` expands the whole committed snapshot. - `actor/committed_state` reads the last committed state. diff --git a/vscode/integration/debugVariables.test.ts b/vscode/integration/debugVariables.test.ts index 68e397a..58b9ab8 100644 --- a/vscode/integration/debugVariables.test.ts +++ b/vscode/integration/debugVariables.test.ts @@ -16,6 +16,7 @@ test("debugger variables show committed data rather than invocation drafts and n await client.close(); await image.stop(); }); + 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}'; 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/, ); }); + +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" }); + } +}); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 63b2553..70c2058 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -411,14 +411,18 @@ async function sourceDebugging(address: string): Promise { async function nativeDebugging(address: string): Promise { const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); const messages: DebugMessage[] = []; + const sessions = new Map(); const changes = new vscode.EventEmitter(); const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", { - createDebugAdapterTracker: () => ({ - onDidSendMessage: (message: DebugMessage) => { - messages.push(message); - changes.fire(); - }, - }), + createDebugAdapterTracker: (debugSession) => { + sessions.set(debugSession.configuration.actorRef, debugSession); + return { + onDidSendMessage: (message: DebugMessage) => { + messages.push(message); + changes.fire(); + }, + }; + }, }); const wait = (predicate: (message: DebugMessage) => boolean, since = 0) => new Promise((resolve, reject) => { @@ -461,6 +465,7 @@ async function nativeDebugging(address: string): Promise { }); let breakpoint: vscode.SourceBreakpoint | undefined; let session: vscode.DebugSession | undefined; + let phase = "normal actor controls"; 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}"; @@ -624,8 +629,71 @@ async function nativeDebugging(address: string): Promise { await vscode.commands.executeCommand("runa.connect", address); await client.system(actor, { 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( - "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 { if (breakpoint) vscode.debug.removeBreakpoints([breakpoint]); diff --git a/vscode/src/debugger.ts b/vscode/src/debugger.ts index 63102ad..896ab03 100644 --- a/vscode/src/debugger.ts +++ b/vscode/src/debugger.ts @@ -269,6 +269,10 @@ export class ActorDebugAdapter { threadId: 1, 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 }> { @@ -280,6 +284,7 @@ export class ActorDebugAdapter { body: { supportsConfigurationDoneRequest: true, supportsLoadedSourcesRequest: true, + supportsExceptionInfoRequest: true, supportsTerminateDebuggee: false, supportSuspendDebuggee: false, }, @@ -291,10 +296,9 @@ export class ActorDebugAdapter { 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"); + if (actor.status.status === "stopped") throw new Error("Cannot attach to a stopped actor"); await this.read(); - if (actor.status.status !== "paused") { + if (actor.status.status !== "paused" && actor.status.status !== "failed") { this.check(); await this.client.system(this.actorRef, { kind: "pause" }); this.check(); @@ -317,6 +321,23 @@ export class ActorDebugAdapter { return { after: () => this.invalidate() }; case "threads": 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": return { body: { sources: this.source ? [this.source] : [] } }; case "source": { @@ -390,6 +411,7 @@ export class ActorDebugAdapter { } case "pause": this.thread(args); + this.requireNonfailed(); await this.read(); this.check(); await this.client.system(this.actorRef, { kind: "pause" }); @@ -398,6 +420,7 @@ export class ActorDebugAdapter { return { after: () => this.invalidate() }; case "continue": this.thread(args); + this.requireNonfailed(); await this.read(); this.check(); await this.client.system(this.actorRef, { kind: "resume" }); @@ -418,6 +441,7 @@ export class ActorDebugAdapter { case "stepIn": case "stepOut": { this.thread(args); + this.requireNonfailed(); if ( args.granularity !== undefined && args.granularity !== "statement" && @@ -456,6 +480,13 @@ export class ActorDebugAdapter { 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): Promise { const source = record(args.source); const requested = args.breakpoints === undefined ? [] : args.breakpoints; diff --git a/vscode/test/debugger.test.ts b/vscode/test/debugger.test.ts index 10abe86..ff5c2ce 100644 --- a/vscode/test/debugger.test.ts +++ b/vscode/test/debugger.test.ts @@ -414,7 +414,7 @@ test("DAP enforces the combined breakpoint limit and rejects malformed requests 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 h = f.harness; 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]?.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); + const stopped = fixture(t); + stopped.snapshot.status = { status: "stopped" }; + await assert.rejects(stopped.harness.request("attach"), /stopped/); + 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); + } });