feat: add failed actor debugger inspection
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4f47a1b3de
commit
191d0d6939
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
sessions.set(debugSession.configuration.actorRef, debugSession);
|
||||||
|
return {
|
||||||
onDidSendMessage: (message: DebugMessage) => {
|
onDidSendMessage: (message: DebugMessage) => {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
changes.fire();
|
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]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue