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:
Steve Biedermann 2026-11-03 21:48:08 +01:00
parent c2781a9bb8
commit e6a8ac84e3
12 changed files with 543 additions and 16 deletions

View File

@ -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

View File

@ -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

View File

@ -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<()>
{

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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/);
});

View File

@ -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");

View File

@ -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"] },
};
}
}

View File

@ -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: () => {

View File

@ -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/);
});

View File

@ -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;