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>
This commit is contained in:
parent
e6a8ac84e3
commit
4f47a1b3de
|
|
@ -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/<JSON Pointer path>`, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<JSON Pointer path>`) 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -545,7 +545,20 @@ async function nativeDebugging(address: string): Promise<void> {
|
|||
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<void> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<number, Value>();
|
||||
private references = new WeakMap<Value, number>();
|
||||
private paths = new WeakMap<Value, string>();
|
||||
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/<JSON Pointer path>; 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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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/<JSON Pointer path> 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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue