feat: add read-only actor snapshot debug scopes
Expose committed actor state, mailbox, configuration and identity as expandable DAP variables with exact integer values, paging and stop-scoped handles. Verify draft isolation against the real image and document the pinned Rune APIs and missing local lifetime mappings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c2781a9bb8
commit
e6a8ac84e3
|
|
@ -56,6 +56,11 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Read-only VS Code debugger actor-snapshot scopes with expandable committed
|
||||
state, mailbox, configuration and identity, exact integer formatting, paged
|
||||
children, and stale-handle invalidation. Invocation drafts and frame locals
|
||||
are not exposed or evaluated.
|
||||
|
||||
- Native VS Code **Debug Actor** attach sessions with exact loaded sources,
|
||||
gutter breakpoint verification, call stacks, pause/continue and bounded toolbar
|
||||
stepping. Sessions terminate on actor removal, implementation/incarnation change
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ VS Code's **Inspect Execution Stack...** actor action. Actor-local source
|
|||
breakpoints and bounded step-into/over/out are implemented through the protocol and VS
|
||||
Code actor actions. **Debug Actor** attaches a native VS Code debug session with
|
||||
gutter breakpoints, the call-stack view, and bounded toolbar stepping. Continue
|
||||
resumes the actor; scheduler ticks remain explicit. Locals, expression evaluation,
|
||||
resumes the actor; scheduler ticks remain explicit. The Variables view exposes
|
||||
a read-only committed actor snapshot with expandable state and mailbox.
|
||||
Frame locals, expression evaluation,
|
||||
and persistent storage remain unimplemented.
|
||||
|
||||
## Design goals
|
||||
|
|
|
|||
|
|
@ -176,6 +176,43 @@ mod tests {
|
|||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn debug_signatures_and_entry_stack_reads_do_not_execute_or_consume_values()
|
||||
-> ExperimentResult<()> {
|
||||
use rune::runtime::{InstAddress, debug::DebugArgs};
|
||||
|
||||
let program =
|
||||
CompiledProgram::compile("pub fn probe(input) { let local = input + 1; local + 1 }")?;
|
||||
let mut vm = program.vm();
|
||||
let mut execution = vm.execute(["probe"], (40_i64,))?;
|
||||
let vm = execution.vm();
|
||||
let (_, signature) = vm
|
||||
.unit()
|
||||
.debug_info()
|
||||
.and_then(|debug| debug.function_at(vm.ip()))
|
||||
.ok_or("missing entry signature")?;
|
||||
let DebugArgs::Named(names) = &signature.args else {
|
||||
return Err("named argument signature unavailable".into());
|
||||
};
|
||||
assert_eq!(
|
||||
names
|
||||
.iter()
|
||||
.map(|name| name.as_ref())
|
||||
.collect::<Vec<&str>>(),
|
||||
["input"]
|
||||
);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(vm.stack().at(InstAddress::ZERO).as_integer::<i64>()?, 40);
|
||||
}
|
||||
for _ in 0..100 {
|
||||
if let Some(value) = execution.step().into_result()? {
|
||||
assert_eq!(rune::from_value::<i64>(value)?, 42);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("probe did not complete".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_frames_map_nested_calls_and_return_addresses_to_exact_source() -> ExperimentResult<()>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -546,7 +546,11 @@ sources, gutter breakpoints, call stacks, pause/resume, and bounded toolbar
|
|||
stepping. Continue does not implicitly tick the shared scheduler. Sessions bind
|
||||
to the connection, implementation and incarnation; a change terminates the
|
||||
session rather than remapping live frames. Detach leaves actor status and
|
||||
breakpoints unchanged. Locals/evaluation and conditional stops remain planned.
|
||||
breakpoints unchanged. Read-only DAP scopes expose owned committed actor
|
||||
snapshots (state, mailbox, configuration and identity), shared across frames,
|
||||
not live invocation drafts. Handles expire on stepping/resume or snapshot
|
||||
replacement. Rune 0.14.2 lacks local-name/lifetime-to-slot debug metadata;
|
||||
frame locals/evaluation and conditional stops remain planned.
|
||||
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||
implemented over the shared `runa-debug` WebSocket transport.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,9 +72,25 @@ The implementation was checked against the downloaded 0.14.2 source:
|
|||
and rejects any other error. This is a feasibility probe, not a recommended
|
||||
long-term scheduler API; avoid depending on display strings in the kernel.
|
||||
- `VmExecution::step`, `Vm::ip`, `Vm::call_frames`, `Vm::stack`, and
|
||||
`Unit::debug_info` expose building blocks for debugging. Only stepping and
|
||||
frame presence are tested here. Source breakpoints, variable inspection,
|
||||
and source-level stepping are not implemented.
|
||||
`Unit::debug_info` expose building blocks for debugging. Follow-up runtime
|
||||
work implements bounded source stepping, breakpoints and head-VM frame
|
||||
inspection; see the [runtime guide](runtime.md).
|
||||
- A regression probe verifies that `DebugSignature.args` exposes named function
|
||||
arguments and that `Stack::at(InstAddress::ZERO)` can read the initial first
|
||||
argument repeatedly without consuming it or changing execution. These are
|
||||
**not** enough to identify live locals: `DebugInfo`/`DebugInst` contain source
|
||||
spans, functions, signatures, labels and comments, but no local-variable
|
||||
lifetime-to-slot mapping. Slots may be moved/reused by compilation. The
|
||||
general `InstAddress::new` constructor and stack bounds are private, and
|
||||
caller stack memory is not publicly enumerable. Do not infer named live
|
||||
values from source declarations, initial argument positions or instruction
|
||||
comments.
|
||||
- Active async steps borrow the VM across pending native awaits. Existing
|
||||
inspection retains the last safe instruction snapshot, not an independently
|
||||
borrowable live stack. No Rune values are retained by the debugger.
|
||||
VS Code's read-only actor scope instead copies already-portable committed
|
||||
state/mailbox data from actor snapshots; it does not claim frame locals,
|
||||
invocation drafts, evaluation or native-object inspection.
|
||||
- Sharing immutable context and compiled code works with thread-local VMs.
|
||||
The test does not move an active VM, execution, or Rune value between threads.
|
||||
- Dropping an execution cancels this controlled local future. Cancellation of
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ Installation clears breakpoints to prevent stale source mappings; pending edits
|
|||
do not. Breakpoints belong to the actor/image, so disconnect does not clear
|
||||
them. Restart on unchanged code retains them. These commands also remain
|
||||
available independently of the native debugger described below. Conditions,
|
||||
hit counts, and variables remain planned.
|
||||
hit counts, and live frame locals remain planned.
|
||||
|
||||
For automation, `runa.setBreakpoints` accepts `{actor_ref: string}` and optional
|
||||
comma-separated 1-based line text (including `""` to clear). `runa.stepSource`
|
||||
|
|
@ -245,7 +245,24 @@ starts a handler. Failed actors must be repaired/restarted before attachment.
|
|||
frames have no invented source location. At handler completion the stack is
|
||||
empty, state commits normally, and the actor remains paused. A failure shows
|
||||
an exception stop with its error text, not a post-mortem stack.
|
||||
- Variables/scopes, watches/evaluation, restart, instruction-level stepping,
|
||||
- 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
|
||||
`queued`), `config`, and `metadata`. A reserved null message is distinct from
|
||||
no reservation. Integers retain their exact signed/unsigned values; typed
|
||||
actor references display as `@reference`, distinct from quoted strings.
|
||||
Records and lists expose named/indexed children and support DAP paging.
|
||||
String previews longer than 256 UTF-16 code units are explicitly abbreviated;
|
||||
full strings remain available in the actor JSON files.
|
||||
- The scope is read-only and contains server-sampled **committed** data, not
|
||||
uncommitted state mutations, buffered sends, Rune locals or native objects.
|
||||
Expansion never evaluates code, ticks the scheduler, or reads live VM slots.
|
||||
Handles remain stable within a snapshot and expire on stepping, resume,
|
||||
external snapshot changes or disconnect. A paused config/mailbox change
|
||||
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.
|
||||
- Frame locals, watches/evaluation, set-variable/set-expression, restart, instruction-level stepping,
|
||||
exception filters, and native/hidden async frames are not implemented. Rune
|
||||
native calls remain nonpreemptible. Unsupported requests fail explicitly.
|
||||
- Disconnecting/stopping the debug session leaves the actor's current status
|
||||
|
|
@ -383,8 +400,8 @@ failures remain explicit notifications in the Runa output channel.
|
|||
|
||||
Compilation checking is single-source, not an LSP: completion, cross-file
|
||||
import resolution through image source paths, and quick fixes remain planned.
|
||||
Debugger controls are described separately above; variable inspection is not
|
||||
implemented.
|
||||
Debugger controls and committed actor-snapshot scopes are described separately
|
||||
above; live Rune local-variable inspection remains unimplemented.
|
||||
|
||||
## Development and validation
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ActorDebugAdapter } from "../src/debugger";
|
||||
import { DebugVariable } from "../src/debugVariables";
|
||||
import { DebugHarness } from "../test/debugHarness";
|
||||
import { ImageClient } from "../src/client";
|
||||
import { parseValue } from "../src/values";
|
||||
import { startImage } from "./server";
|
||||
|
||||
test("debugger variables show committed data rather than invocation drafts and never execute Rune", 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(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 state = parseValue('{"items":[9223372036854775807],"other":{"flag":true}}');
|
||||
const actor = await client.spawn(file.path, state);
|
||||
await client.send(actor, parseValue('{"value":-9223372036854775808}'));
|
||||
const h = new DebugHarness(
|
||||
(send) =>
|
||||
new ActorDebugAdapter(
|
||||
client,
|
||||
actor,
|
||||
() => "runa-execution://1/loaded/test.rn",
|
||||
() => {},
|
||||
send,
|
||||
(path) => `runa-image://${image.address}/source/${path}.rn`,
|
||||
() => true,
|
||||
),
|
||||
);
|
||||
t.after(() => h.adapter.dispose());
|
||||
await h.attach();
|
||||
const bps = await h.request<{ breakpoints: { verified: boolean }[] }>("setBreakpoints", {
|
||||
source: { sourceReference: 1 },
|
||||
breakpoints: [{ line: 6 }],
|
||||
});
|
||||
assert.equal(bps.breakpoints[0]?.verified, true);
|
||||
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 === "breakpoint",
|
||||
);
|
||||
const before = await client.inspectActor(actor);
|
||||
const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 });
|
||||
assert.equal(stack.stackFrames.length, 2);
|
||||
const scope = async (frame: number) => {
|
||||
const reply = await h.request<{ scopes: { variablesReference: number }[] }>("scopes", {
|
||||
frameId: frame,
|
||||
});
|
||||
return reply.scopes[0]!.variablesReference;
|
||||
};
|
||||
const reference = await scope(stack.stackFrames[0]!.id);
|
||||
assert.equal(
|
||||
await scope(stack.stackFrames[1]!.id),
|
||||
reference,
|
||||
"actor scope is shared, not fabricated per-frame locals",
|
||||
);
|
||||
const children = async (reference: number) =>
|
||||
(
|
||||
await h.request<{ variables: DebugVariable[] }>("variables", {
|
||||
variablesReference: reference,
|
||||
})
|
||||
).variables;
|
||||
const roots = await children(reference);
|
||||
const stateFields = await children(
|
||||
roots.find((field) => field.name === "committed_state")!.variablesReference,
|
||||
);
|
||||
const items = stateFields.find((field) => field.name === "items")!;
|
||||
assert.equal(items.indexedVariables, 1, "the invocation's push has not committed");
|
||||
assert.equal((await children(items.variablesReference))[0]?.value, "9223372036854775807");
|
||||
const mailbox = await children(
|
||||
roots.find((field) => field.name === "mailbox")!.variablesReference,
|
||||
);
|
||||
const reserved = await children(
|
||||
mailbox.find((field) => field.name === "reserved")!.variablesReference,
|
||||
);
|
||||
const message = await children(reserved[0]!.variablesReference);
|
||||
assert.equal(message[0]?.value, "-9223372036854775808");
|
||||
assert.deepEqual(
|
||||
await client.inspectActor(actor),
|
||||
before,
|
||||
"scope expansion must not mutate or advance the actor",
|
||||
);
|
||||
let completed = false;
|
||||
for (let count = 0; count < 10; count++) {
|
||||
const since = h.messages.length;
|
||||
await h.request("next", { threadId: 1 });
|
||||
await h.wait((message) => message.event === "stopped", since);
|
||||
if (!(await client.inspectActor(actor)).execution) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(completed, true);
|
||||
assert.deepEqual(
|
||||
(await client.inspectActor(actor)).state,
|
||||
parseValue(
|
||||
'{"items":[9223372036854775807,{"value":-9223372036854775808}],"other":{"flag":true}}',
|
||||
),
|
||||
);
|
||||
await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/);
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import { ImageClient } from "../src/client";
|
|||
import { parseValue } from "../src/values";
|
||||
import { ExecutionDocuments, frameSelection } from "../src/executionProvider";
|
||||
import { DebugMessage } from "../src/debugger";
|
||||
import { DebugVariable } from "../src/debugVariables";
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const image = await startImage();
|
||||
|
|
@ -533,9 +534,28 @@ async function nativeDebugging(address: string): Promise<void> {
|
|||
await selectedLine(uri, 5);
|
||||
assert.deepEqual(await client.inspectActor(other), otherBefore);
|
||||
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
|
||||
assert.deepEqual(await session.customRequest("scopes", { frameId: stack.stackFrames[0]!.id }), {
|
||||
scopes: [],
|
||||
const scopes: { scopes: { name: string; variablesReference: number }[] } =
|
||||
await session.customRequest("scopes", { frameId: stack.stackFrames[0]!.id });
|
||||
assert.match(scopes.scopes[0]!.name, /committed; not frame locals/);
|
||||
const scopeReference = scopes.scopes[0]!.variablesReference;
|
||||
const variables: { variables: DebugVariable[] } = await session.customRequest("variables", {
|
||||
variablesReference: scopeReference,
|
||||
});
|
||||
const committed = variables.variables.find((variable) => variable.name === "committed_state");
|
||||
assert.equal(committed?.value, "10");
|
||||
assert.equal(committed?.variablesReference, 0);
|
||||
assert.deepEqual(committed?.presentationHint.attributes, ["readOnly"]);
|
||||
const mailbox = variables.variables.find((variable) => variable.name === "mailbox")!;
|
||||
const mailboxFields: { variables: DebugVariable[] } = await session.customRequest("variables", {
|
||||
variablesReference: mailbox.variablesReference,
|
||||
});
|
||||
const reserved = mailboxFields.variables.find((variable) => variable.name === "reserved")!;
|
||||
assert.equal(reserved.indexedVariables, 1);
|
||||
const reservedMessages: { variables: DebugVariable[] } = await session.customRequest(
|
||||
"variables",
|
||||
{ variablesReference: reserved.variablesReference },
|
||||
);
|
||||
assert.equal(reservedMessages.variables[0]?.value, "null");
|
||||
since = messages.length;
|
||||
await vscode.commands.executeCommand("workbench.action.debug.stepOut");
|
||||
await wait((message) => message.event === "stopped", since);
|
||||
|
|
@ -543,6 +563,10 @@ async function nativeDebugging(address: string): Promise<void> {
|
|||
assert.equal(stack.stackFrames[0]?.name, "handle");
|
||||
assert.equal(stack.stackFrames[0]?.line, 3);
|
||||
await selectedLine(uri, 2);
|
||||
await assert.rejects(
|
||||
Promise.resolve(session.customRequest("variables", { variablesReference: scopeReference })),
|
||||
/Stale/,
|
||||
);
|
||||
for (let count = 0; count < 10 && (await client.inspectActor(actor)).execution; count++) {
|
||||
since = messages.length;
|
||||
await vscode.commands.executeCommand("workbench.action.debug.stepOver");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import { ActorSnapshot, Value } from "./protocol";
|
||||
import { formatValue } from "./values";
|
||||
|
||||
export interface DebugVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
type: string;
|
||||
variablesReference: number;
|
||||
namedVariables?: number;
|
||||
indexedVariables?: number;
|
||||
presentationHint: { attributes: ["readOnly"] };
|
||||
}
|
||||
|
||||
function record(data: Record<string, Value>): Value {
|
||||
return { type: "record", data };
|
||||
}
|
||||
|
||||
function string(data: string): Value {
|
||||
return { type: "string", data };
|
||||
}
|
||||
|
||||
/** Owned committed data only; never holds a Rune value or evaluates actor code. */
|
||||
export class ActorVariables {
|
||||
private nextReference = 0;
|
||||
private readonly values = new Map<number, Value>();
|
||||
private references = new WeakMap<Value, number>();
|
||||
private root?: number;
|
||||
|
||||
clear(): void {
|
||||
this.values.clear();
|
||||
this.references = new WeakMap();
|
||||
this.root = undefined;
|
||||
}
|
||||
|
||||
capture(snapshot: ActorSnapshot): void {
|
||||
this.clear();
|
||||
const actor = structuredClone(snapshot);
|
||||
this.root = this.reference(
|
||||
record({
|
||||
committed_state: actor.state,
|
||||
mailbox: record({
|
||||
reserved: { type: "list", data: actor.reserved === null ? [] : [actor.reserved] },
|
||||
queued: { type: "list", data: actor.queued },
|
||||
}),
|
||||
config: record({ log_level: string(actor.log_level) }),
|
||||
metadata: record({
|
||||
actor_ref: { type: "actor_ref", data: actor.actor_ref },
|
||||
mailbox_id: { type: "integer", data: actor.mailbox_id },
|
||||
implementation_id: string(actor.implementation_id),
|
||||
incarnation: { type: "integer", data: actor.incarnation },
|
||||
behavior_path:
|
||||
actor.behavior_path == null ? { type: "null" } : string(actor.behavior_path),
|
||||
status: string(actor.status.status),
|
||||
...(actor.status.message === undefined ? {} : { failure: string(actor.status.message) }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
scope(): {
|
||||
name: string;
|
||||
variablesReference: number;
|
||||
expensive: boolean;
|
||||
namedVariables: number;
|
||||
} {
|
||||
if (this.root === undefined) throw new Error("Actor snapshot is unavailable while running");
|
||||
return {
|
||||
name: "Actor snapshot (committed; not frame locals)",
|
||||
variablesReference: this.root,
|
||||
expensive: false,
|
||||
namedVariables: 4,
|
||||
};
|
||||
}
|
||||
|
||||
variables(reference: number, filter?: unknown, start = 0, count = 0): DebugVariable[] {
|
||||
const value = this.values.get(reference);
|
||||
if (!value) throw new Error("Stale or unknown variables reference");
|
||||
if (filter !== undefined && filter !== "named" && filter !== "indexed")
|
||||
throw new Error("Invalid variables filter");
|
||||
if (value.type !== "record" && value.type !== "list")
|
||||
throw new Error("Variable has no children");
|
||||
const indexed = value.type === "list";
|
||||
if ((filter === "named" && indexed) || (filter === "indexed" && !indexed)) return [];
|
||||
const entries: [string, Value][] = indexed
|
||||
? value.data.map((child, index) => [String(index), child])
|
||||
: Object.entries(value.data);
|
||||
return entries
|
||||
.slice(start, count === 0 ? undefined : start + count)
|
||||
.map(([name, value]) => this.variable(name, value));
|
||||
}
|
||||
|
||||
private reference(value: Value): number {
|
||||
const previous = this.references.get(value);
|
||||
if (previous !== undefined) return previous;
|
||||
const reference = ++this.nextReference;
|
||||
this.references.set(value, reference);
|
||||
this.values.set(reference, value);
|
||||
return reference;
|
||||
}
|
||||
|
||||
private variable(name: string, value: Value): DebugVariable {
|
||||
const children =
|
||||
value.type === "list"
|
||||
? value.data.length
|
||||
: value.type === "record"
|
||||
? Object.keys(value.data).length
|
||||
: 0;
|
||||
let preview: string;
|
||||
if (value.type === "list") preview = `List(${children})`;
|
||||
else if (value.type === "record") preview = `Record(${children})`;
|
||||
else if (value.type === "actor_ref") preview = `@${value.data}`;
|
||||
else if (value.type === "string" && value.data.length > 256)
|
||||
preview = `${JSON.stringify(value.data.slice(0, 256))}... (${value.data.length} UTF-16 code units)`;
|
||||
else preview = formatValue(value);
|
||||
return {
|
||||
name,
|
||||
value: preview,
|
||||
type: value.type,
|
||||
variablesReference: children ? this.reference(value) : 0,
|
||||
...(value.type === "list" ? { indexedVariables: children } : {}),
|
||||
...(value.type === "record" ? { namedVariables: children } : {}),
|
||||
presentationHint: { attributes: ["readOnly"] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { ImageClient } from "./client";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { ActorVariables } from "./debugVariables";
|
||||
import {
|
||||
ActorSnapshot,
|
||||
ExecutionInspection,
|
||||
isActorRef,
|
||||
SourceBreakpoint,
|
||||
|
|
@ -81,6 +84,8 @@ export class ActorDebugAdapter {
|
|||
private readonly breakpointGroups = new Map<string, BreakpointGroup>();
|
||||
private readonly breakpointStates = new Map<number, boolean>();
|
||||
private readonly frames = new Map<number, StackFrame>();
|
||||
private readonly variables = new ActorVariables();
|
||||
private actorSnapshot?: ActorSnapshot;
|
||||
private nextFrame = 0;
|
||||
private nextBreakpoint = 0;
|
||||
private stopKey?: string;
|
||||
|
|
@ -158,6 +163,8 @@ export class ActorDebugAdapter {
|
|||
this.closed = true;
|
||||
this.updates.dispose();
|
||||
this.frames.clear();
|
||||
this.variables.clear();
|
||||
this.actorSnapshot = undefined;
|
||||
this.breakpointGroups.clear();
|
||||
this.breakpointStates.clear();
|
||||
}
|
||||
|
|
@ -231,14 +238,18 @@ export class ActorDebugAdapter {
|
|||
this.paused = false;
|
||||
this.stopKey = undefined;
|
||||
this.frames.clear();
|
||||
this.variables.clear();
|
||||
this.actorSnapshot = undefined;
|
||||
return;
|
||||
}
|
||||
const execution = inspection.execution;
|
||||
const key = `${actor.status.status}:${execution?.vm_steps}:${execution?.frames[0]?.instruction_pointer}:${inspection.debugger?.stop_reason}`;
|
||||
if (key === this.stopKey) return;
|
||||
if (key === this.stopKey && isDeepStrictEqual(actor, this.actorSnapshot)) return;
|
||||
this.stopKey = key;
|
||||
this.paused = true;
|
||||
this.frames.clear();
|
||||
this.actorSnapshot = structuredClone(actor);
|
||||
this.variables.capture(actor);
|
||||
for (const frame of execution?.frames ?? []) this.frames.set(++this.nextFrame, frame);
|
||||
const reason = inspection.debugger?.stop_reason;
|
||||
this.event("stopped", {
|
||||
|
|
@ -292,7 +303,7 @@ export class ActorDebugAdapter {
|
|||
return {
|
||||
after: () => {
|
||||
this.output(
|
||||
"Attached to one actor. Continue resumes scheduling eligibility but does not tick the image; use Runa: Advance Scheduler. Disconnect leaves actor status and breakpoints unchanged. Locals/evaluation are not available.",
|
||||
"Attached to one actor. Continue resumes scheduling eligibility but does not tick the image; use Runa: Advance Scheduler. Disconnect leaves actor status and breakpoints unchanged. Variables show a committed actor snapshot, not invocation drafts or frame locals. Evaluation is unavailable.",
|
||||
);
|
||||
this.event("initialized");
|
||||
},
|
||||
|
|
@ -341,7 +352,18 @@ export class ActorDebugAdapter {
|
|||
}
|
||||
case "scopes":
|
||||
if (!this.frames.has(integer(args.frameId))) throw new Error("Stale or unknown frame");
|
||||
return { body: { scopes: [] } };
|
||||
return { body: { scopes: [this.variables.scope()] } };
|
||||
case "variables":
|
||||
return {
|
||||
body: {
|
||||
variables: this.variables.variables(
|
||||
integer(args.variablesReference, 1),
|
||||
args.filter,
|
||||
args.start === undefined ? 0 : integer(args.start),
|
||||
args.count === undefined ? 0 : integer(args.count),
|
||||
),
|
||||
},
|
||||
};
|
||||
case "pause":
|
||||
this.thread(args);
|
||||
await this.read();
|
||||
|
|
@ -359,6 +381,8 @@ export class ActorDebugAdapter {
|
|||
this.paused = false;
|
||||
this.stopKey = undefined;
|
||||
this.frames.clear();
|
||||
this.variables.clear();
|
||||
this.actorSnapshot = undefined;
|
||||
return {
|
||||
body: { allThreadsContinued: true },
|
||||
after: () => {
|
||||
|
|
@ -388,6 +412,8 @@ export class ActorDebugAdapter {
|
|||
);
|
||||
this.check();
|
||||
this.frames.clear();
|
||||
this.variables.clear();
|
||||
this.actorSnapshot = undefined;
|
||||
this.stopKey = undefined;
|
||||
return {
|
||||
after: () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ActorVariables } from "../src/debugVariables";
|
||||
import { actor, REF } from "./fixtures";
|
||||
|
||||
test("actor variables preserve exact values, types, escaped strings and typed references", () => {
|
||||
const variables = new ActorVariables();
|
||||
variables.capture(actor());
|
||||
const scope = variables.scope();
|
||||
assert.match(scope.name, /committed; not frame locals/);
|
||||
const roots = variables.variables(scope.variablesReference);
|
||||
assert.deepEqual(
|
||||
roots.map((variable) => variable.name),
|
||||
["committed_state", "mailbox", "config", "metadata"],
|
||||
);
|
||||
const state = roots[0]!;
|
||||
assert.equal(state.value, "Record(5)");
|
||||
assert.equal(state.namedVariables, 5);
|
||||
const fields = variables.variables(state.variablesReference);
|
||||
assert.equal(fields.find((field) => field.name === "minimum")?.value, "-9223372036854775808");
|
||||
assert.equal(fields.find((field) => field.name === "maximum")?.value, "9223372036854775807");
|
||||
assert.equal(fields.find((field) => field.name === "reference")?.value, `@${REF}`);
|
||||
assert.equal(fields.find((field) => field.name === "reference")?.type, "actor_ref");
|
||||
assert.equal(fields.find((field) => field.name === "text")?.type, "string");
|
||||
assert.equal(
|
||||
fields.find((field) => field.name === "text")?.value,
|
||||
JSON.stringify(`@${REF} "雪"`),
|
||||
);
|
||||
assert.ok(fields.every((field) => field.presentationHint.attributes[0] === "readOnly"));
|
||||
const list = fields.find((field) => field.name === "list")!;
|
||||
assert.equal(list.indexedVariables, 2);
|
||||
assert.deepEqual(
|
||||
variables.variables(list.variablesReference).map((child) => child.value),
|
||||
["null", "true"],
|
||||
);
|
||||
const metadata = variables.variables(roots[3]!.variablesReference);
|
||||
assert.equal(
|
||||
metadata.find((field) => field.name === "mailbox_id")?.value,
|
||||
"18446744073709551615",
|
||||
);
|
||||
const mailbox = variables.variables(roots[1]!.variablesReference);
|
||||
const reserved = mailbox.find((field) => field.name === "reserved")!;
|
||||
assert.equal(
|
||||
reserved.indexedVariables,
|
||||
1,
|
||||
"a reserved null message must not be confused with no reservation",
|
||||
);
|
||||
assert.equal(variables.variables(reserved.variablesReference)[0]?.value, "null");
|
||||
});
|
||||
|
||||
test("variable handles are stable per snapshot, stale after clear, and snapshots never alias inputs", () => {
|
||||
const snapshot = actor();
|
||||
const variables = new ActorVariables();
|
||||
variables.capture(snapshot);
|
||||
const scope = variables.scope();
|
||||
const state = variables.variables(scope.variablesReference)[0]!;
|
||||
assert.deepEqual(variables.variables(scope.variablesReference)[0], state);
|
||||
snapshot.state = { type: "integer", data: 999n };
|
||||
snapshot.queued.push({ type: "null" });
|
||||
assert.equal(variables.variables(scope.variablesReference)[0]?.value, "Record(5)");
|
||||
const mailbox = variables.variables(
|
||||
variables.variables(scope.variablesReference)[1]!.variablesReference,
|
||||
);
|
||||
assert.equal(mailbox.find((field) => field.name === "queued")?.indexedVariables, 1);
|
||||
variables.capture(snapshot);
|
||||
assert.throws(() => variables.variables(scope.variablesReference), /Stale/);
|
||||
const next = variables.scope();
|
||||
assert.notEqual(scope.variablesReference, next.variablesReference);
|
||||
assert.equal(variables.variables(next.variablesReference)[0]?.value, "999");
|
||||
variables.clear();
|
||||
assert.throws(() => variables.scope(), /unavailable/);
|
||||
assert.throws(() => variables.variables(next.variablesReference), /Stale/);
|
||||
});
|
||||
|
||||
test("variables support named/indexed paging and bounded previews without recursive serialization", () => {
|
||||
const snapshot = actor();
|
||||
snapshot.state = {
|
||||
type: "list",
|
||||
data: [
|
||||
{ type: "string", data: "\n".repeat(300) },
|
||||
{ type: "record", data: {} },
|
||||
{ type: "list", data: [] },
|
||||
{ type: "integer", data: 1n },
|
||||
],
|
||||
};
|
||||
snapshot.reserved = null;
|
||||
const variables = new ActorVariables();
|
||||
variables.capture(snapshot);
|
||||
const root = variables.scope().variablesReference;
|
||||
const state = variables.variables(root)[0]!.variablesReference;
|
||||
assert.deepEqual(variables.variables(state, "named"), []);
|
||||
assert.deepEqual(variables.variables(root, "indexed"), []);
|
||||
assert.deepEqual(
|
||||
variables.variables(state, "indexed", 1, 2).map((field) => field.name),
|
||||
["1", "2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
variables.variables(state, undefined, 2, 0).map((field) => field.name),
|
||||
["2", "3"],
|
||||
);
|
||||
assert.deepEqual(variables.variables(state, "indexed", 100), []);
|
||||
const fields = variables.variables(state);
|
||||
assert.equal(fields[0]?.value, `${JSON.stringify("\n".repeat(256))}... (300 UTF-16 code units)`);
|
||||
assert.equal(fields[1]?.variablesReference, 0);
|
||||
assert.equal(fields[2]?.variablesReference, 0);
|
||||
assert.throws(() => variables.variables(root, "invalid"), /filter/);
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@ function fixture(t: TestContext) {
|
|||
};
|
||||
}
|
||||
|
||||
test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes and bounded modes", async (t) => {
|
||||
test("DAP attach pauses only its actor, exposes exact stack/source, actor scopes and bounded modes", async (t) => {
|
||||
const f = fixture(t);
|
||||
const h = f.harness;
|
||||
await h.attach();
|
||||
|
|
@ -131,7 +131,16 @@ test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes
|
|||
const frame = stack.stackFrames[0]!;
|
||||
assert.equal(frame.line, 2);
|
||||
assert.equal(frame.column, 5);
|
||||
assert.deepEqual(await h.request("scopes", { frameId: frame.id }), { scopes: [] });
|
||||
const scopes = await h.request<{ scopes: { name: string; variablesReference: number }[] }>(
|
||||
"scopes",
|
||||
{ frameId: frame.id },
|
||||
);
|
||||
assert.match(scopes.scopes[0]!.name, /not frame locals/);
|
||||
const reference = scopes.scopes[0]!.variablesReference;
|
||||
const variables = await h.request<{ variables: { name: string; value: string }[] }>("variables", {
|
||||
variablesReference: reference,
|
||||
});
|
||||
assert.equal(variables.variables[0]?.name, "committed_state");
|
||||
assert.deepEqual(await h.request("source", { sourceReference: frame.source.sourceReference }), {
|
||||
content: f.inspection.source,
|
||||
mimeType: "text/x-rune",
|
||||
|
|
@ -142,8 +151,17 @@ test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes
|
|||
await h.wait((message) => message.event === "stopped", since);
|
||||
}
|
||||
assert.deepEqual(f.modes, ["into", "over", "out"]);
|
||||
await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/);
|
||||
await assert.rejects(h.request("scopes", { frameId: frame.id }), /Stale/);
|
||||
await assert.rejects(h.request("evaluate", { expression: "state" }), /Unsupported/);
|
||||
await assert.rejects(
|
||||
h.request("setVariable", {
|
||||
variablesReference: reference,
|
||||
name: "committed_state",
|
||||
value: "1",
|
||||
}),
|
||||
/Unsupported/,
|
||||
);
|
||||
await assert.rejects(h.request("next", { threadId: 2 }), /thread/);
|
||||
await assert.rejects(
|
||||
h.request("stepIn", { threadId: 1, granularity: "instruction" }),
|
||||
|
|
@ -159,6 +177,42 @@ test("DAP attach pauses only its actor, exposes exact stack/source, empty scopes
|
|||
assert.ok(h.messages.some((message) => message.event === "terminated"));
|
||||
});
|
||||
|
||||
test("DAP refreshes committed snapshots on external edits and invalidates variables on resume", async (t) => {
|
||||
const f = fixture(t);
|
||||
const h = f.harness;
|
||||
await h.attach();
|
||||
const scope = async () => {
|
||||
const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 });
|
||||
const scopes = await h.request<{ scopes: { variablesReference: number }[] }>("scopes", {
|
||||
frameId: stack.stackFrames[0]!.id,
|
||||
});
|
||||
return scopes.scopes[0]!.variablesReference;
|
||||
};
|
||||
const before = await scope();
|
||||
const since = h.messages.length;
|
||||
f.snapshot.state = { type: "integer", data: 9223372036854775807n };
|
||||
h.adapter.invalidate();
|
||||
await h.wait((message) => message.event === "stopped", since);
|
||||
await assert.rejects(h.request("variables", { variablesReference: before }), /Stale/);
|
||||
const after = await scope();
|
||||
const fields = await h.request<{ variables: { value: string }[] }>("variables", {
|
||||
variablesReference: after,
|
||||
});
|
||||
assert.equal(fields.variables[0]?.value, "9223372036854775807");
|
||||
await assert.rejects(h.request("variables", { variablesReference: after, start: -1 }), /integer/);
|
||||
await assert.rejects(
|
||||
h.request("variables", { variablesReference: after, count: 1.5 }),
|
||||
/integer/,
|
||||
);
|
||||
await assert.rejects(
|
||||
h.request("variables", { variablesReference: after, filter: "all" }),
|
||||
/filter/,
|
||||
);
|
||||
await h.request("continue", { threadId: 1 });
|
||||
await assert.rejects(h.request("variables", { variablesReference: after }), /Stale/);
|
||||
assert.equal(f.mutations(), 2, "variables and scopes are reads, not actor execution");
|
||||
});
|
||||
|
||||
test("DAP gutter breakpoints require exact loaded sources and explicitly reject unsupported forms", async (t) => {
|
||||
const f = fixture(t);
|
||||
const h = f.harness;
|
||||
|
|
|
|||
Loading…
Reference in New Issue