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
|
||||
|
||||
- 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/<JSON Pointer path>`, with Add to Watch paths on actor variables,
|
||||
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`;
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -411,14 +411,18 @@ 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 sessions = new Map<string, vscode.DebugSession>();
|
||||
const changes = new vscode.EventEmitter<void>();
|
||||
const tracker = vscode.debug.registerDebugAdapterTrackerFactory("runa", {
|
||||
createDebugAdapterTracker: () => ({
|
||||
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<DebugMessage>((resolve, reject) => {
|
||||
|
|
@ -461,6 +465,7 @@ async function nativeDebugging(address: string): Promise<void> {
|
|||
});
|
||||
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<void> {
|
|||
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]);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): Promise<unknown> {
|
||||
const source = record(args.source);
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue