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;