diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6c39d..aae753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 37dd2c7..92692b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/runa-rune/src/execution.rs b/crates/runa-rune/src/execution.rs index 0bb40c4..677b693 100644 --- a/crates/runa-rune/src/execution.rs +++ b/crates/runa-rune/src/execution.rs @@ -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::>(), + ["input"] + ); + for _ in 0..2 { + assert_eq!(vm.stack().at(InstAddress::ZERO).as_integer::()?, 40); + } + for _ in 0..100 { + if let Some(value) = execution.step().into_result()? { + assert_eq!(rune::from_value::(value)?, 42); + return Ok(()); + } + } + Err("probe did not complete".into()) + } + #[test] fn stack_frames_map_nested_calls_and_return_addresses_to_exact_source() -> ExperimentResult<()> { diff --git a/docs/architecture.md b/docs/architecture.md index 3677a65..abbc95e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/rune-feasibility.md b/docs/rune-feasibility.md index 03a9843..e154571 100644 --- a/docs/rune-feasibility.md +++ b/docs/rune-feasibility.md @@ -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 diff --git a/vscode/README.md b/vscode/README.md index 3967df6..a407d5e 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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 diff --git a/vscode/integration/debugVariables.test.ts b/vscode/integration/debugVariables.test.ts new file mode 100644 index 0000000..7a1e27f --- /dev/null +++ b/vscode/integration/debugVariables.test.ts @@ -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/); +}); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index befce4f..39f4070 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -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 { const image = await startImage(); @@ -533,9 +534,28 @@ async function nativeDebugging(address: string): Promise { 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 { 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"); diff --git a/vscode/src/debugVariables.ts b/vscode/src/debugVariables.ts new file mode 100644 index 0000000..23098e6 --- /dev/null +++ b/vscode/src/debugVariables.ts @@ -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): 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(); + private references = new WeakMap(); + 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"] }, + }; + } +} diff --git a/vscode/src/debugger.ts b/vscode/src/debugger.ts index 86c4efd..8d2d361 100644 --- a/vscode/src/debugger.ts +++ b/vscode/src/debugger.ts @@ -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(); private readonly breakpointStates = new Map(); private readonly frames = new Map(); + 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: () => { diff --git a/vscode/test/debugVariables.test.ts b/vscode/test/debugVariables.test.ts new file mode 100644 index 0000000..cb5956f --- /dev/null +++ b/vscode/test/debugVariables.test.ts @@ -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/); +}); diff --git a/vscode/test/debugger.test.ts b/vscode/test/debugger.test.ts index ff8b21a..e33b33c 100644 --- a/vscode/test/debugger.test.ts +++ b/vscode/test/debugger.test.ts @@ -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;