From 4f47a1b3de1f4f2eef00512437ab52c64d8dab9a Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 3 Nov 2026 22:47:18 +0100 Subject: [PATCH] feat: add read-only actor snapshot watches Resolve actor JSON Pointer paths in Watch and Debug Console without evaluating Rune. Add watch paths to Variables entries and preserve expandable results, exact integers and snapshot-scoped handles. Validate malformed paths, stale frames and real-image draft isolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++ README.md | 3 +- docs/architecture.md | 7 +- docs/rune-feasibility.md | 3 +- vscode/README.md | 24 ++++++- vscode/integration/debugVariables.test.ts | 36 ++++++++++ vscode/integration/host.ts | 18 +++++ vscode/src/debugVariables.ts | 43 +++++++++-- vscode/src/debugger.ts | 26 ++++++- vscode/test/debugVariables.test.ts | 87 +++++++++++++++++++++++ vscode/test/debugger.test.ts | 52 +++++++++++++- 11 files changed, 292 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae753f..db1a4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ All notable changes to this project will be documented in this file. ### Added +- Read-only snapshot-path watches and Debug Console lookups using + `actor/`, with Add to Watch paths on actor variables, + expandable results, exact integers and explicit rejection of Rune expressions. + - 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 diff --git a/README.md b/README.md index 92692b6..89867e9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ 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. The Variables view exposes a read-only committed actor snapshot with expandable state and mailbox. -Frame locals, expression evaluation, +Watch and Debug Console accept snapshot paths such as `actor/committed_state`; +they never execute actor code. Frame locals, Rune expression evaluation, and persistent storage remain unimplemented. ## Design goals diff --git a/docs/architecture.md b/docs/architecture.md index abbc95e..30be0eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -549,8 +549,11 @@ session rather than remapping live frames. Detach leaves actor status and 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. +replacement. Snapshot-path watches (`actor/`) and Debug +Console lookups resolve only owned committed data and reuse the same expandable +values. They do not execute Rune or access live VM slots. Rune 0.14.2 lacks +local-name/lifetime-to-slot debug metadata; frame locals/Rune expression +evaluation and conditional stops remain planned. 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 e154571..c5ca2f6 100644 --- a/docs/rune-feasibility.md +++ b/docs/rune-feasibility.md @@ -90,7 +90,8 @@ The implementation was checked against the downloaded 0.14.2 source: 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. + invocation drafts, Rune expression evaluation or native-object inspection. + Snapshot-path watches resolve only copied committed data in the client. - 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 a407d5e..5b01c8c 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -262,7 +262,29 @@ starts a handler. Failed actors must be repaired/restarted before attachment. refreshes the stop and scope rather than changing data behind existing handles. Handler boundaries and post-mortem failures have no frames, hence no DAP scopes; use the actor files there. -- Frame locals, watches/evaluation, set-variable/set-expression, restart, instruction-level stepping, +- Watch and Debug Console support **snapshot paths**, not Rune expressions: + - `actor` expands the whole committed snapshot. + - `actor/committed_state` reads the last committed state. + - `actor/committed_state/items/0` reads a list element. + - `actor/mailbox/reserved/0` reads the reserved message, if present. + - `actor/config/log_level` and `actor/metadata/actor_ref` read configuration + and identity. + Use **Add to Watch** on a variable to generate its path automatically. + Path segments follow JSON Pointer escaping: `/` in a record key becomes + `~1`, and `~` becomes `~0`. An empty segment selects an empty record key. + List indices are canonical zero-based decimals (`0`, `1`, ...), not + expressions or negative indices. Paths are limited to 4,096 UTF-16 code + units and 64 segments after `actor`. + Results share the Variables view's previews, exact integers, expandable + children and read-only hints. No functions, operators, assignments, + property getters, implicit conversion, or actor execution are supported. + Missing keys, invalid indices/escapes, malformed paths and unsupported + contexts fail explicitly. Hover evaluation is not offered. + Watches resolve against the latest sampled paused snapshot, including + handler boundaries with no frame. Requests with a frame ID require a + current frame; after resume, snapshot lookup is unavailable until another + pause. Expanded result handles expire just like Variables handles. +- Frame locals, Rune expression 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 diff --git a/vscode/integration/debugVariables.test.ts b/vscode/integration/debugVariables.test.ts index 7a1e27f..68e397a 100644 --- a/vscode/integration/debugVariables.test.ts +++ b/vscode/integration/debugVariables.test.ts @@ -84,6 +84,28 @@ test("debugger variables show committed data rather than invocation drafts and n ); const message = await children(reserved[0]!.variablesReference); assert.equal(message[0]?.value, "-9223372036854775808"); + const watch = await h.request<{ result: string; variablesReference: number }>("evaluate", { + expression: "actor/committed_state/items", + context: "watch", + }); + assert.equal(watch.result, "List(1)"); + assert.equal(watch.variablesReference, items.variablesReference); + assert.equal( + ( + await h.request<{ result: string }>("evaluate", { + expression: "actor/mailbox/reserved/0/value", + context: "repl", + }) + ).result, + "-9223372036854775808", + ); + await assert.rejects( + h.request("evaluate", { + expression: "actor/committed_state/items.push(1)", + context: "repl", + }), + /not found/, + ); assert.deepEqual( await client.inspectActor(actor), before, @@ -107,4 +129,18 @@ test("debugger variables show committed data rather than invocation drafts and n ), ); await assert.rejects(h.request("variables", { variablesReference: reference }), /Stale/); + assert.equal( + ( + await h.request<{ result: string }>("evaluate", { + expression: "actor/committed_state/items", + context: "watch", + }) + ).result, + "List(2)", + "the same watch resolves against the new committed stop snapshot", + ); + await assert.rejects( + h.request("variables", { variablesReference: watch.variablesReference }), + /Stale/, + ); }); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 39f4070..63b2553 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -545,7 +545,20 @@ async function nativeDebugging(address: string): Promise { assert.equal(committed?.value, "10"); assert.equal(committed?.variablesReference, 0); assert.deepEqual(committed?.presentationHint.attributes, ["readOnly"]); + assert.equal(committed?.evaluateName, "actor/committed_state"); + const watched: { result: string; type: string; variablesReference: number } = + await session.customRequest("evaluate", { + expression: committed.evaluateName, + context: "watch", + frameId: stack.stackFrames[0]!.id, + }); + assert.equal(watched.result, "10"); + assert.equal(watched.type, "integer"); + const watchedMailbox: { result: string; variablesReference: number } = + await session.customRequest("evaluate", { expression: "actor/mailbox", context: "repl" }); + assert.equal(watchedMailbox.result, "Record(2)"); const mailbox = variables.variables.find((variable) => variable.name === "mailbox")!; + assert.equal(watchedMailbox.variablesReference, mailbox.variablesReference); const mailboxFields: { variables: DebugVariable[] } = await session.customRequest("variables", { variablesReference: mailbox.variablesReference, }); @@ -576,6 +589,11 @@ async function nativeDebugging(address: string): Promise { assert.equal(completed.execution, null); assert.equal(completed.status.status, "paused"); assert.deepEqual(completed.state, parseValue("12")); + const committedWatch: { result: string } = await session.customRequest("evaluate", { + expression: "actor/committed_state", + context: "watch", + }); + assert.equal(committedWatch.result, "12"); assert.deepEqual(await session.customRequest("stackTrace", { threadId: 1 }), { stackFrames: [], totalFrames: 0, diff --git a/vscode/src/debugVariables.ts b/vscode/src/debugVariables.ts index 23098e6..07e4d63 100644 --- a/vscode/src/debugVariables.ts +++ b/vscode/src/debugVariables.ts @@ -8,6 +8,7 @@ export interface DebugVariable { variablesReference: number; namedVariables?: number; indexedVariables?: number; + evaluateName: string; presentationHint: { attributes: ["readOnly"] }; } @@ -24,11 +25,13 @@ export class ActorVariables { private nextReference = 0; private readonly values = new Map(); private references = new WeakMap(); + private paths = new WeakMap(); private root?: number; clear(): void { this.values.clear(); this.references = new WeakMap(); + this.paths = new WeakMap(); this.root = undefined; } @@ -55,6 +58,7 @@ export class ActorVariables { }), }), ); + this.paths.set(this.values.get(this.root)!, "actor"); } scope(): { @@ -84,9 +88,39 @@ export class ActorVariables { 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)); + return entries.slice(start, count === 0 ? undefined : start + count).map(([name, child]) => { + const path = `${this.paths.get(value)!}/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`; + this.paths.set(child, path); + return this.variable(name, child, path); + }); + } + + evaluate(expression: string): DebugVariable { + if (this.root === undefined) throw new Error("Actor snapshot is unavailable while running"); + if (expression.length > 4096) throw new Error("Snapshot path exceeds 4096 UTF-16 code units"); + const [root, ...tokens] = expression.split("/"); + if (root !== "actor" || tokens.length > 64) + throw new Error("Use actor or actor/; Rune expressions are unsupported"); + let value = this.values.get(this.root)!; + for (const token of tokens) { + if (/~(?:[^01]|$)/.test(token)) throw new Error("Invalid JSON Pointer escape; use ~0 or ~1"); + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if (value.type === "record") { + if (!Object.hasOwn(value.data, key)) + throw new Error(`Snapshot field not found: ${JSON.stringify(key)}`); + value = value.data[key]!; + } else if (value.type === "list") { + if ( + !/^(0|[1-9]\d*)$/.test(key) || + !Number.isSafeInteger(Number(key)) || + Number(key) >= value.data.length + ) + throw new Error("Snapshot list index is invalid or out of range"); + value = value.data[Number(key)]!; + } else throw new Error("Snapshot path traverses a scalar value"); + } + this.paths.set(value, expression); + return this.variable(expression, value, expression); } private reference(value: Value): number { @@ -98,7 +132,7 @@ export class ActorVariables { return reference; } - private variable(name: string, value: Value): DebugVariable { + private variable(name: string, value: Value, path: string): DebugVariable { const children = value.type === "list" ? value.data.length @@ -116,6 +150,7 @@ export class ActorVariables { name, value: preview, type: value.type, + evaluateName: path, variablesReference: children ? this.reference(value) : 0, ...(value.type === "list" ? { indexedVariables: children } : {}), ...(value.type === "record" ? { namedVariables: children } : {}), diff --git a/vscode/src/debugger.ts b/vscode/src/debugger.ts index 8d2d361..63102ad 100644 --- a/vscode/src/debugger.ts +++ b/vscode/src/debugger.ts @@ -303,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. Variables show a committed actor snapshot, not invocation drafts or frame locals. Evaluation is unavailable.", + "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 and actor/ watches read committed snapshots, not invocation drafts or Rune expressions.", ); this.event("initialized"); }, @@ -364,6 +364,30 @@ export class ActorDebugAdapter { ), }, }; + case "evaluate": { + if (typeof args.expression !== "string") throw new Error("Expected a snapshot path"); + if ( + args.context !== undefined && + (typeof args.context !== "string" || + !["watch", "repl", "variables"].includes(args.context)) + ) + throw new Error( + "Snapshot paths are supported only in Watch, Debug Console and Variables", + ); + if (args.frameId !== undefined && !this.frames.has(integer(args.frameId))) + throw new Error("Stale or unknown frame"); + const variable = this.variables.evaluate(args.expression); + return { + body: { + result: variable.value, + type: variable.type, + variablesReference: variable.variablesReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables, + presentationHint: variable.presentationHint, + }, + }; + } case "pause": this.thread(args); await this.read(); diff --git a/vscode/test/debugVariables.test.ts b/vscode/test/debugVariables.test.ts index cb5956f..f721bca 100644 --- a/vscode/test/debugVariables.test.ts +++ b/vscode/test/debugVariables.test.ts @@ -48,6 +48,93 @@ test("actor variables preserve exact values, types, escaped strings and typed re assert.equal(variables.variables(reserved.variablesReference)[0]?.value, "null"); }); +test("snapshot watches resolve JSON Pointer paths and expose expandable evaluate names", () => { + const snapshot = actor(); + snapshot.state = { + type: "record", + data: Object.fromEntries([ + [ + "a/b", + { + type: "record", + data: { + "~key": { type: "list", data: [{ type: "integer", data: -9223372036854775808n }] }, + }, + }, + ], + ["", { type: "null" }], + ["__proto__", { type: "string", data: "ordinary own field" }], + ["~1", { type: "bool", data: true }], + ['"quoted"\n雪', { type: "integer", data: 7n }], + ]), + }; + const variables = new ActorVariables(); + variables.capture(snapshot); + assert.equal(variables.evaluate("actor").value, "Record(4)"); + const path = "actor/committed_state/a~1b/~0key/0"; + assert.equal(variables.evaluate(path).value, "-9223372036854775808"); + assert.equal(variables.evaluate("actor/committed_state/").value, "null"); + assert.equal(variables.evaluate("actor/committed_state/__proto__").value, '"ordinary own field"'); + assert.equal(variables.evaluate("actor/committed_state/~01").value, "true"); + assert.equal(variables.evaluate('actor/committed_state/"quoted"\n雪').value, "7"); + assert.throws(() => variables.evaluate("actor/committed_state/constructor"), /not found/); + const pending = [variables.scope().variablesReference]; + while (pending.length) { + for (const variable of variables.variables(pending.pop()!)) { + assert.deepEqual(variables.evaluate(variable.evaluateName), { + ...variable, + name: variable.evaluateName, + }); + if (variable.variablesReference) pending.push(variable.variablesReference); + } + } +}); + +test("snapshot watches reject expressions, malformed pointers, scalar traversal and invalid indices", () => { + const variables = new ActorVariables(); + variables.capture(actor()); + for (const expression of [ + "", + "/committed_state", + "state", + "actor.committed_state", + "actor()", + "actor = 1", + "actor/committed_state/text/0", + "actor/committed_state/list/-1", + "actor/committed_state/list/01", + "actor/committed_state/list/1.0", + "actor/committed_state/list/2", + "actor/committed_state/list/9007199254740992", + "actor/committed_state/list/constructor", + "actor/committed_state/list/-", + "actor/committed_state/list/ 0", + "actor/committed_state/list/0 + 1", + "actor/committed_state/~", + "actor/committed_state/~2", + "actor/committed_state/~00~", + `actor/${"x/".repeat(65)}`, + `actor/${"x".repeat(4096)}`, + ]) + assert.throws(() => variables.evaluate(expression), expression); + assert.equal(variables.evaluate("actor/committed_state/list/0").value, "null"); + const reference = variables.evaluate("actor/committed_state").variablesReference; + variables.clear(); + assert.throws(() => variables.evaluate("actor/committed_state"), /unavailable/); + assert.throws(() => variables.variables(reference), /Stale/); +}); + +test("snapshot watch path length accepts the exact documented boundary", () => { + const prefix = "actor/committed_state/"; + const key = "x".repeat(4096 - prefix.length); + const snapshot = actor(); + snapshot.state = { type: "record", data: { [key]: { type: "bool", data: true } } }; + const variables = new ActorVariables(); + variables.capture(snapshot); + assert.equal(variables.evaluate(prefix + key).value, "true"); + assert.throws(() => variables.evaluate(prefix + key + "x"), /exceeds 4096/); +}); + test("variable handles are stable per snapshot, stale after clear, and snapshots never alias inputs", () => { const snapshot = actor(); const variables = new ActorVariables(); diff --git a/vscode/test/debugger.test.ts b/vscode/test/debugger.test.ts index e33b33c..10abe86 100644 --- a/vscode/test/debugger.test.ts +++ b/vscode/test/debugger.test.ts @@ -153,7 +153,10 @@ test("DAP attach pauses only its actor, exposes exact stack/source, actor scopes 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("evaluate", { expression: "state" }), + /Rune expressions are unsupported/, + ); await assert.rejects( h.request("setVariable", { variablesReference: reference, @@ -177,6 +180,53 @@ test("DAP attach pauses only its actor, exposes exact stack/source, actor scopes assert.ok(h.messages.some((message) => message.event === "terminated")); }); +test("DAP snapshot watches read owned data, reject execution and honor frame/snapshot lifetimes", async (t) => { + const f = fixture(t); + const h = f.harness; + await h.attach(); + const stack = await h.request<{ stackFrames: { id: number }[] }>("stackTrace", { threadId: 1 }); + const frameId = stack.stackFrames[0]!.id; + const evaluate = (expression: string, context = "watch") => + h.request<{ result: string; type: string; variablesReference: number }>("evaluate", { + expression, + context, + frameId, + }); + assert.equal((await evaluate("actor/committed_state/maximum")).result, "9223372036854775807"); + assert.equal( + (await evaluate("actor/committed_state/minimum", "repl")).result, + "-9223372036854775808", + ); + const result = await evaluate("actor/committed_state", "variables"); + assert.equal(result.type, "record"); + assert.equal(result.result, "Record(5)"); + assert.ok(result.variablesReference > 0); + await assert.rejects(evaluate("actor/committed_state", "hover"), /only in Watch/); + await assert.rejects(evaluate("ctx.send(actor, null)"), /Rune expressions/); + await assert.rejects(evaluate("actor/committed_state/missing"), /not found/); + await assert.rejects(h.request("evaluate", { expression: null }), /snapshot path/); + const since = h.messages.length; + f.snapshot.state = { type: "integer", data: 2n }; + h.adapter.invalidate(); + await h.wait((message) => message.event === "stopped", since); + await assert.rejects(evaluate("actor/committed_state"), /Stale/); + await assert.rejects( + h.request("variables", { variablesReference: result.variablesReference }), + /Stale/, + ); + assert.equal( + (await h.request<{ result: string }>("evaluate", { expression: "actor/committed_state" })) + .result, + "2", + ); + await h.request("continue", { threadId: 1 }); + await assert.rejects( + h.request("evaluate", { expression: "actor/committed_state" }), + /unavailable while running/, + ); + assert.equal(f.mutations(), 2, "watches must not invoke actor execution or mutations"); +}); + test("DAP refreshes committed snapshots on external edits and invalidates variables on resume", async (t) => { const f = fixture(t); const h = f.harness;