feat: add bounded source step-over and step-out

Extend mode-aware stepping across runtime, image API, protocol and VS Code while retaining default step-into behavior. Test recursive calls, nested breakpoints, async and lifecycle boundaries, and real editor navigation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 21:14:06 +01:00
parent 19eaeb447c
commit 68afb435f3
19 changed files with 469 additions and 29 deletions

View File

@ -56,6 +56,9 @@ All notable changes to this project will be documented in this file.
### Added
- Bounded source step-over and step-out through the runtime, image API, protocol,
and VS Code actor commands, preserving nested breakpoints and async stop boundaries.
- Actor-local, implementation-bound source breakpoints with explicit line
verification and scheduler pauses that retain active drafts and buffered sends.
- Bounded source step-into through the image API/protocol and VS Code actor

View File

@ -33,8 +33,8 @@ slices, and actor snapshots report basic active-execution progress. Native
callbacks are not preemptible. The debugger foundation exposes bounded active Rune
stack frames and exact loaded source through read-only protocol inspection and
VS Code's **Inspect Execution Stack...** actor action. Actor-local source
breakpoints and bounded step-into are implemented through the protocol and VS
Code actor actions; step-over/out, locals, DAP, and persistent storage remain
breakpoints and bounded step-into/over/out are implemented through the protocol and VS
Code actor actions; locals, DAP, and persistent storage remain
unimplemented.
## Design goals

View File

@ -48,6 +48,15 @@ pub enum DebugStopReason {
Removed,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepMode {
#[default]
Into,
Over,
Out,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebuggerSnapshot {
pub breakpoints: Vec<SourceBreakpoint>,

View File

@ -9,7 +9,7 @@ mod value;
pub use diagnostic::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
pub use execution::{
DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame,
StackFrameKind,
StackFrameKind, StepMode,
};
pub use log::{LogEntry, LogLevel};
pub use value::{ActorRef, MAX_VALUE_BYTES, MAX_VALUE_DEPTH, MAX_VALUE_NODES, Value};

View File

@ -382,7 +382,17 @@ impl ImageApi {
expected: ImplementationId,
budget: NonZeroUsize,
) -> RuntimeResult<runa_core::DebugStopReason> {
let result = self.runtime.step_source(actor, expected, budget);
self.step_source_mode(actor, expected, budget, runa_core::StepMode::Into)
}
pub fn step_source_mode(
&mut self,
actor: ActorRef,
expected: ImplementationId,
budget: NonZeroUsize,
mode: runa_core::StepMode,
) -> RuntimeResult<runa_core::DebugStopReason> {
let result = self.runtime.step_source_mode(actor, expected, budget, mode);
self.collect_runtime_events();
result
}

View File

@ -3,7 +3,7 @@
pub use runa_core::Value;
pub use runa_core::{
DebugStopReason, DebuggerSnapshot, ExecutionInspection, SourceBreakpoint, StackFrame,
StackFrameKind,
StackFrameKind, StepMode,
};
pub use runa_core::{DiagnosticSeverity, SourceDiagnostic, SourcePosition, SourceRange};
pub use runa_core::{LogEntry, LogLevel};
@ -70,6 +70,7 @@ impl Request {
actor_ref,
implementation_id,
instruction_budget,
..
} => {
validate_actor_ref(actor_ref)?;
if implementation_id.is_empty()
@ -217,6 +218,8 @@ pub enum Command {
actor_ref: String,
implementation_id: String,
instruction_budget: usize,
#[serde(default)]
mode: StepMode,
},
InspectExecution {
actor_ref: String,
@ -649,6 +652,48 @@ mod tests {
}
}
#[test]
fn source_step_modes_are_additive_and_reject_unknown_modes() {
let mut wire = serde_json::json!({
"version": PROTOCOL_VERSION, "request_id": "step",
"command": {"operation": "step_source", "parameters": {
"actor_ref": "ABCDEFGHIJKLMNOPQRSTU", "implementation_id": "impl", "instruction_budget": 100
}}
});
let default: Request = serde_json::from_value(wire.clone()).unwrap();
assert!(matches!(
default.command,
Command::StepSource {
mode: StepMode::Into,
..
}
));
for (text, mode) in [
("into", StepMode::Into),
("over", StepMode::Over),
("out", StepMode::Out),
] {
wire["command"]["parameters"]["mode"] = serde_json::json!(text);
let request: Request = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(request.validate(), Ok(()));
assert!(
matches!(request.command, Command::StepSource {mode: actual, ..} if actual == mode)
);
assert_eq!(
serde_json::to_value(request).unwrap()["command"]["parameters"]["mode"],
text
);
}
for value in [
serde_json::json!("next"),
serde_json::json!(null),
serde_json::json!(1),
] {
wire["command"]["parameters"]["mode"] = value;
assert!(serde_json::from_value::<Request>(wire.clone()).is_err());
}
}
#[test]
fn request_uses_a_stable_tagged_wire_shape() {
let value = serde_json::to_value(request(Command::Send {

View File

@ -228,6 +228,16 @@ impl Runtime {
id: ActorRef,
expected: ImplementationId,
budget: NonZeroUsize,
) -> ExperimentResult<DebugStopReason> {
self.step_source_mode(id, expected, budget, runa_core::StepMode::Into)
}
pub fn step_source_mode(
&mut self,
id: ActorRef,
expected: ImplementationId,
budget: NonZeroUsize,
mode: runa_core::StepMode,
) -> ExperimentResult<DebugStopReason> {
if budget.get() > 10_000 {
return Err("debug step budget must not exceed 10000".into());
@ -270,8 +280,16 @@ impl Runtime {
if artifact
.program()
.current_source_line(&stack)
.is_some_and(|line| {
.is_some_and(|line| match mode {
runa_core::StepMode::Into => {
stack.call_depth != origin.call_depth || Some(line) != origin_line
}
runa_core::StepMode::Over => {
stack.call_depth < origin.call_depth
|| (stack.call_depth == origin.call_depth
&& Some(line) != origin_line)
}
runa_core::StepMode::Out => stack.call_depth < origin.call_depth,
})
{
reason = DebugStopReason::Step;
@ -838,6 +856,182 @@ mod tests {
use super::*;
use runa_core::MAX_VALUE_NODES;
#[test]
fn debugger_step_over_skips_nested_calls_and_step_out_returns_to_caller() -> ExperimentResult<()>
{
use runa_core::StepMode;
let mut runtime = Runtime::default();
let code = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) {\n let next = outer(state);\n next + 1\n}\nfn outer(value) {\n let result = inner(value);\n result + 1\n}\nfn inner(value) {\n value + 1\n}",
)?;
let actor = runtime.spawn(code, Value::Integer(3), capacity(2))?;
runtime.set_breakpoints(actor, code, &[1])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
runtime.set_breakpoints(actor, code, &[])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Over)?,
DebugStopReason::Step
);
let over = runtime.inspect_execution(actor)?.unwrap();
assert_eq!(over.call_depth, 0);
assert_eq!(over.frames[0].function.as_deref(), Some("handle"));
assert_eq!(over.frames[0].range.unwrap().start.line, 2);
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(3));
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Out)?,
DebugStopReason::Completed
);
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(6));
assert_eq!(runtime.inspect(actor)?.status, Status::Paused);
runtime.restart(actor)?;
runtime.set_breakpoints(actor, code, &[8])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
assert_eq!(runtime.inspect_execution(actor)?.unwrap().call_depth, 2);
runtime.set_breakpoints(actor, code, &[])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Out)?,
DebugStopReason::Step
);
let out = runtime.inspect_execution(actor)?.unwrap();
assert_eq!(out.call_depth, 1);
assert_eq!(out.frames[0].function.as_deref(), Some("outer"));
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(6));
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Out)?,
DebugStopReason::Step
);
assert_eq!(runtime.inspect_execution(actor)?.unwrap().call_depth, 0);
Ok(())
}
#[test]
fn debugger_step_modes_respect_nested_breakpoints_and_recursive_depth() -> ExperimentResult<()>
{
use runa_core::StepMode;
for mode in [StepMode::Over, StepMode::Out] {
let mut runtime = Runtime::default();
let code = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) {\n recurse(4)\n}\nfn recurse(n) {\n if n == 0 { return 7; }\n let value = recurse(n - 1);\n value + 1\n}",
)?;
let actor = runtime.spawn(code, Value::Integer(0), capacity(2))?;
runtime.set_breakpoints(actor, code, &[1])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
runtime.set_breakpoints(actor, code, &[5])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), mode)?,
DebugStopReason::Breakpoint
);
assert_eq!(runtime.inspect_execution(actor)?.unwrap().call_depth, 1);
runtime.set_breakpoints(actor, code, &[])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Over)?,
DebugStopReason::Step
);
let over = runtime.inspect_execution(actor)?.unwrap();
assert_eq!(
over.call_depth, 1,
"recursive calls must not count as returning to the origin frame"
);
assert_eq!(over.frames[0].range.unwrap().start.line, 6);
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(0));
assert_eq!(
runtime.step_source_mode(actor, code, capacity(1000), StepMode::Out)?,
DebugStopReason::Step
);
assert_eq!(runtime.inspect_execution(actor)?.unwrap().call_depth, 0);
}
Ok(())
}
#[test]
fn debugger_over_and_out_keep_budget_await_and_failure_boundaries() -> ExperimentResult<()> {
use runa_core::StepMode;
for mode in [StepMode::Over, StepMode::Out] {
let mut runtime = Runtime::default();
let code = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) {\n spin()\n}\nfn spin() { loop {} }",
)?;
let actor = runtime.spawn(code, Value::Null, capacity(2))?;
runtime.set_breakpoints(actor, code, &[1])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
let before = runtime.inspect_execution(actor)?.unwrap().vm_steps;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(20), mode)?,
DebugStopReason::Budget
);
assert_eq!(
runtime.inspect_execution(actor)?.unwrap().vm_steps,
before + 20
);
assert_eq!(runtime.inspect(actor)?.status, Status::Paused);
let code = publish(
&mut runtime,
"pub async fn handle(ctx, state, sender, message) {\n native_suspend(7).await;\n 42\n}",
)?;
let actor = runtime.spawn(code, Value::Integer(1), capacity(2))?;
runtime.set_breakpoints(actor, code, &[1])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
runtime.set_breakpoints(actor, code, &[])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(100), mode)?,
DebugStopReason::Awaiting
);
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(1));
assert_eq!(runtime.inspect(actor)?.status, Status::Paused);
runtime.resume(actor)?;
runtime.tick(capacity(100));
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(42));
let code = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) {\n crash()\n}\nfn crash() { 1 / 0 }",
)?;
let actor = runtime.spawn(code, Value::Integer(1), capacity(2))?;
runtime.set_breakpoints(actor, code, &[1])?;
runtime.send(actor, Value::Null)?;
runtime.tick(capacity(100));
runtime.set_breakpoints(actor, code, &[])?;
assert_eq!(
runtime.step_source_mode(actor, code, capacity(100), mode)?,
DebugStopReason::Failed
);
assert!(matches!(runtime.inspect(actor)?.status, Status::Failed(_)));
assert_eq!(runtime.inspect(actor)?.reserved, Some(Value::Null));
let code = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) { state }\npub fn on_shutdown(state) {\n state + 1\n}",
)?;
let actor = runtime.spawn(code, Value::Integer(1), capacity(2))?;
runtime.set_breakpoints(actor, code, &[2])?;
runtime.stop(actor)?;
runtime.tick(capacity(100));
runtime.set_breakpoints(actor, code, &[])?;
let mut removed = false;
for _ in 0..10 {
if runtime.step_source_mode(actor, code, capacity(100), mode)?
== DebugStopReason::Removed
{
removed = true;
break;
}
}
assert!(removed);
assert!(runtime.inspect(actor).is_err());
}
Ok(())
}
#[test]
fn debugger_breakpoints_preserve_transactions_and_step_only_one_actor() -> ExperimentResult<()>
{

View File

@ -87,6 +87,7 @@ impl Server {
actor_ref,
implementation_id,
instruction_budget,
mode,
} => {
let actor = parse_actor_ref(&actor_ref)?;
let expected = self.image.inspect(actor).map_err(api_error)?.implementation;
@ -97,7 +98,7 @@ impl Server {
));
}
self.image
.step_source(
.step_source_mode(
actor,
expected,
NonZeroUsize::new(instruction_budget).ok_or_else(|| {
@ -106,6 +107,7 @@ impl Server {
"step budget must be positive",
)
})?,
mode,
)
.map(|reason| Reply::DebugStepCompleted { reason })
.map_err(api_error)

View File

@ -539,9 +539,9 @@ locations through read-only execution inspection. VS Code navigates immutable
exact-loaded-source documents, including old code during pending replacements.
Await locations represent the last safe snapshot; native/hidden async frames,
locals, post-mortem stacks, and DAP remain unimplemented. Actor-specific
implementation-bound line breakpoints and bounded step-into now preserve active
implementation-bound line breakpoints and bounded step-into/over/out now preserve active
drafts/effects and use exact loaded-source mappings. These are command-based
controls, not gutter breakpoints; step-over/out and conditional stops remain planned.
controls, not gutter breakpoints; conditional stops remain planned.
Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport.

View File

@ -67,7 +67,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension.
| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` |
| `inspect_execution` | `actor_ref` | `execution_inspected`: loaded source and optional execution stack |
| `set_breakpoints` | `actor_ref`, `implementation_id`, `lines` | `breakpoints_set`: `{breakpoints}` |
| `step_source` | `actor_ref`, `implementation_id`, `instruction_budget` | `debug_step_completed`: `{reason}` |
| `step_source` | `actor_ref`, `implementation_id`, `instruction_budget`, optional `mode` | `debug_step_completed`: `{reason}` |
| `system` | `actor_ref`, `message` | `acknowledged` |
A file is `{path, source_id, source}` and a commit is
@ -163,7 +163,7 @@ stopped address once, not all subsequent hits.
`step_source` requires a paused actor with an active handler/shutdown invocation,
and a positive budget no greater than 10,000. It advances only that actor, stopping
at the next mapped line or call-depth change (step-into), another breakpoint,
according to the selected mode, another breakpoint,
pending async operation, completion/failure/removal, or budget exhaustion.
`reason` is one of `step`, `breakpoint`, `budget`, `awaiting`, `completed`,
`failed`, `removed`. Failure is an executed actor transition with explicit reason
@ -173,13 +173,23 @@ boundary; drafts/sends commit only on normal completion. Another explicit step
may poll a pending async operation once; steps do not block waiting for it.
Native calls remain cooperative, not preemptible.
`mode` is `into` (default when absent), `over`, or `out`; other values are rejected.
Into stops at another mapped line or call-depth change. Over skips deeper calls
and stops on another mapped line at the original depth or any mapped shallower
frame. Out stops at a mapped shallower frame, or completes the invocation when
started at the outermost frame. Nested breakpoints interrupt any mode. Budgets,
pending awaits, failure and removal retain their usual stop semantics. Each
request uses the current frame as its origin; no pending over/out plan survives
a budget/await/breakpoint stop. Omitted-mode requests keep their existing into
behavior. Over/out require a server implementing this additive mode field.
`execution_inspected` adds `debugger: {breakpoints, stop_reason}` even when
`execution` is null. Older replies may omit it; Rust defaults to an empty
configuration and no reason. `stop_reason` is nullable and uses the same reason
strings as step replies. Installation clears breakpoints; restart/cancel retain
them for unchanged loaded code and clear stop reasons. Debugger state is actor
local, shared by clients, and in-memory; it is not owned by a client connection.
There are no conditions, hit counts, step-over/out, or gutter/DAP breakpoints yet.
There are no conditions, hit counts, or gutter/DAP breakpoints yet.
System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}`
or `{kind: "become", path: "actors/other"}`. This is a trusted host request;

View File

@ -38,12 +38,20 @@ Other actors still receive their ordinary scheduler slices.
`step_source` requires a paused **active** invocation and an expected loaded
implementation identity. It polls only that actor until the next mapped line
or call-depth change, a breakpoint, pending async operation, completion, failure,
or the instruction budget. It is step-into, not step-over/out. Budgets range
or the instruction budget. The optional mode defaults to `into`; `over` skips
deeper calls and stops at another line in the same frame or a mapped caller
location, while `out` stops only at a mapped location in a shallower frame.
At the outermost frame, step-out runs toward handler completion. Breakpoints
in deeper calls still take priority in all modes. Depth comparisons distinguish
recursive calls even when the function and source line are the same. Budgets range
from 1 to 10,000 (VS Code uses 1,000); same-line loops and unmapped instructions
are bounded rather than allowed to run indefinitely. Native calls are still
not preemptible. A pending async poll returns immediately with `awaiting`;
another explicit step may poll it again. Native/hidden future stacks remain
outside source stepping.
Each request starts from the actor's current frame and line. After a budget,
await, or breakpoint stop, another request starts a new step from that stop;
there is no hidden step plan retained across requests.
The actor stays paused even after successful handler completion, with normal
state/send commit and no automatic start of the next message. Resume then
@ -57,7 +65,7 @@ stale line mappings. Removal discards debugger state. Steps and breakpoint
configuration changes broadcast `image_changed`.
These are explicit actor controls, not a DAP session or VS Code gutter
breakpoints. CLI debugger commands, conditions, hit counts, step-over/out, locals,
breakpoints. CLI debugger commands, conditions, hit counts, locals,
and post-mortem stacks are not implemented.
## Actor repair and logging

View File

@ -150,7 +150,7 @@ or Debug Adapter Protocol support yet.
For automation, `runa.inspectExecution` accepts `{actor_ref: string}` and an
optional zero-based frame index to navigate without the picker.
### Source breakpoints and step-into
### Source breakpoints and stepping
Use the actor context menu or Command Palette:
@ -170,6 +170,15 @@ Use the actor context menu or Command Palette:
4. **Resume Actor** and advance the scheduler to continue normally. Resume
crosses the stopped breakpoint once; a loop can hit it again.
**Step Over Source Line** skips deeper function calls and stops at the next
mapped line in the original frame or at a mapped caller location.
**Step Out of Function** runs toward a mapped caller location; from the outermost
handler frame it runs toward completion. Both still stop at nested breakpoints,
pending awaits, failures, or the 1,000-instruction budget. Every command uses the
current frame/line as its origin: after an interrupted step, the next command
starts a new step rather than resuming a hidden plan. All three commands open
the exact loaded-source location and leave surviving nonfailed actors paused.
An idle actor cannot be stepped: set a breakpoint, send, then tick first, or
pause an already active handler. A completed step commits normally and leaves
the actor paused at the handler boundary, without consuming another message.
@ -178,12 +187,14 @@ Same-line loops are bounded and pending awaits return immediately.
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 are command-based controls,
not editor-gutter breakpoints or an F5/debug-toolbar session. Step-over/out,
conditions, hit counts, and variables remain planned.
not editor-gutter breakpoints or an F5/debug-toolbar session. Conditions, hit
counts, and variables remain planned.
For automation, `runa.setBreakpoints` accepts `{actor_ref: string}` and optional
comma-separated 1-based line text (including `""` to clear). `runa.stepSource`
accepts `{actor_ref: string}` and returns its stop reason.
`runa.stepOver` and `runa.stepOut` accept the same argument and return stop reasons.
Use a server built with mode-aware stepping for these new commands.
For automation, the `runa.connect` command also accepts an explicit endpoint
string, and `runa.inspect` accepts an object with an `actor_ref` field.

View File

@ -1,10 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ImageClient } from "../src/client";
import { ProtocolError } from "../src/protocol";
import { ProtocolError, STEP_MODES } from "../src/protocol";
import { parseValue } from "../src/values";
import { startImage, control } from "./server";
test(
"real image supports over/out and gives nested breakpoints priority",
{ timeout: 10000 },
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 let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}";
const file = await client.writeSource("tests/step_modes", null, source);
const actor = await client.spawn(file.path, parseValue("10"));
const other = await client.spawn(file.path, parseValue("20"));
const loaded = await client.inspectExecution(actor);
await client.setBreakpoints(actor, loaded.implementation_id, [1]);
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await client.setBreakpoints(actor, loaded.implementation_id, []);
await client.send(other, parseValue("null"));
const otherBefore = await client.inspectActor(other);
assert.equal(await client.stepSource(actor, loaded.implementation_id, 1000, "over"), "step");
let stack = (await client.inspectExecution(actor)).execution;
assert.equal(stack?.call_depth, 0n);
assert.equal(stack?.frames[0]?.range?.start.line, 2);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
assert.deepEqual(await client.inspectActor(other), otherBefore);
assert.equal(
await client.stepSource(actor, loaded.implementation_id, 1000, "out"),
"completed",
);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("12"));
assert.equal((await client.inspectActor(actor)).status.status, "paused");
await client.system(actor, { kind: "restart" });
await client.setBreakpoints(actor, loaded.implementation_id, [1]);
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await client.setBreakpoints(actor, loaded.implementation_id, [5]);
assert.equal(
await client.stepSource(actor, loaded.implementation_id, 1000, "over"),
"breakpoint",
);
assert.equal((await client.inspectExecution(actor)).execution?.call_depth, 1n);
await client.setBreakpoints(actor, loaded.implementation_id, []);
assert.equal(await client.stepSource(actor, loaded.implementation_id, 1000, "out"), "step");
stack = (await client.inspectExecution(actor)).execution;
assert.equal(stack?.call_depth, 0n);
assert.equal(stack?.frames[0]?.function, "handle");
await assert.rejects(
control(image.address, {
operation: "step_source",
parameters: {
actor_ref: actor,
implementation_id: loaded.implementation_id,
instruction_budget: 100,
mode: "unknown",
},
}),
/Control connection closed/,
);
for (const mode of STEP_MODES) {
await assert.rejects(
client.stepSource(actor, "stale", 100, mode),
(error) => error instanceof ProtocolError && error.code === "conflict",
);
}
},
);
test(
"actor breakpoints and source stepping preserve drafts, isolate actors and reject stale implementations",
{ timeout: 10000 },

View File

@ -344,7 +344,7 @@ async function sourceDebugging(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
try {
const source =
"pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next\n}\nfn nested(value) {\n value + 1\n}";
"pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}";
const file = await client.writeSource("tests/source_debugging", null, source);
const actor = await client.spawn(file.path, parseValue("10"));
const selected = { actor_ref: actor };
@ -366,6 +366,14 @@ async function sourceDebugging(address: string): Promise<void> {
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "");
assert.deepEqual((await client.inspectExecution(actor)).debugger?.breakpoints, []);
assert.equal(await vscode.commands.executeCommand("runa.stepOut", selected), "step");
const caller = await client.inspectExecution(actor);
assert.equal(caller.execution?.call_depth, 0n);
assert.equal(caller.execution?.frames[0]?.function, "handle");
assert.equal(
vscode.window.activeTextEditor?.selection.start.line,
caller.execution?.frames[0]?.range?.start.line,
);
let completed = false;
for (let i = 0; i < 20; i++) {
if ((await vscode.commands.executeCommand("runa.stepSource", selected)) === "completed") {
@ -376,10 +384,21 @@ async function sourceDebugging(address: string): Promise<void> {
assert.ok(completed);
const snapshot = await client.inspectActor(actor);
assert.equal(snapshot.status.status, "paused");
assert.deepEqual(snapshot.state, parseValue("11"));
assert.deepEqual(snapshot.state, parseValue("12"));
await client.system(actor, { kind: "restart" });
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "2");
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "");
assert.equal(await vscode.commands.executeCommand("runa.stepOver", selected), "step");
const over = await client.inspectExecution(actor);
assert.equal(over.execution?.call_depth, 0n);
assert.equal(over.execution?.frames[0]?.range?.start.line, 2);
assert.equal(vscode.window.activeTextEditor?.selection.start.line, 2);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("12"));
await client.system(actor, { kind: "kill" });
console.log(
"Runa source debugger: actor breakpoints, nested step navigation, clearing and transactional completion passed",
"Runa source debugger: actor breakpoints, step into/over/out navigation, clearing and transactional completion passed",
);
} finally {
await client.close();

View File

@ -51,6 +51,18 @@
}
],
"commands": [
{
"command": "runa.stepOver",
"title": "Step Over Source Line",
"category": "Runa",
"enablement": "runa.connected"
},
{
"command": "runa.stepOut",
"title": "Step Out of Function",
"category": "Runa",
"enablement": "runa.connected"
},
{
"command": "runa.stepSource",
"title": "Step Into Source Line",
@ -323,6 +335,16 @@
}
],
"view/item/context": [
{
"command": "runa.stepOver",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
"group": "debug@2a"
},
{
"command": "runa.stepOut",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",
"group": "debug@2b"
},
{
"command": "runa.stepSource",
"when": "view == runa.actors && viewItem == runa.actor && runa.connected",

View File

@ -7,6 +7,8 @@ import {
ExecutionInspection,
SourceBreakpoint,
DebugStopReason,
STEP_MODES,
StepMode,
decodeNotification,
decodeResponse,
decodeValue,
@ -263,13 +265,15 @@ export class ImageClient {
actorRef: string,
implementationId: string,
budget = 1000,
mode: StepMode = "into",
): Promise<DebugStopReason> {
if (
!isActorRef(actorRef) ||
!implementationId ||
!Number.isSafeInteger(budget) ||
budget < 1 ||
budget > 10000
budget > 10000 ||
!STEP_MODES.includes(mode)
)
throw new Error("Invalid actor, implementation or source step budget");
const reply = await this.request({
@ -278,6 +282,7 @@ export class ImageClient {
actor_ref: actorRef,
implementation_id: implementationId,
instruction_budget: budget,
mode,
},
});
if (reply.kind !== "debug_step_completed") throw new Error("Expected source step result");

View File

@ -7,6 +7,7 @@ import {
SystemMessage,
LOG_LEVELS,
LogLevel,
StepMode,
} from "./protocol";
import { RefreshQueue } from "./refresh";
import { parseValue, formatValue } from "./values";
@ -447,18 +448,23 @@ export function activate(context: vscode.ExtensionContext): {
return breakpoints;
};
const stepSource = async (selected?: { actor_ref: string }) => {
const stepSource = async (selected?: { actor_ref: string }, mode: StepMode = "into") => {
const connection = active();
const actorRef = await chooseActor(selected);
if (!actorRef) return;
connection.check();
const inspection = await connection.client.inspectExecution(actorRef);
connection.check();
const reason = await connection.client.stepSource(actorRef, inspection.implementation_id);
const reason = await connection.client.stepSource(
actorRef,
inspection.implementation_id,
1000,
mode,
);
connection.check();
await refresh();
connection.check();
output.appendLine(`Source step @${actorRef}: ${reason}`);
output.appendLine(`Source step ${mode} @${actorRef}: ${reason}`);
if (
reason === "step" ||
reason === "breakpoint" ||
@ -939,7 +945,9 @@ export function activate(context: vscode.ExtensionContext): {
command("runa.inspect", inspect),
command("runa.inspectExecution", inspectExecution),
command("runa.setBreakpoints", setBreakpoints),
command("runa.stepSource", stepSource),
command("runa.stepSource", (selected?: { actor_ref: string }) => stepSource(selected)),
command("runa.stepOver", (selected?: { actor_ref: string }) => stepSource(selected, "over")),
command("runa.stepOut", (selected?: { actor_ref: string }) => stepSource(selected, "out")),
command("runa.editBehavior", editBehavior),
command("runa.goToBehavior", goToBehavior),
command("runa.updateBehavior", updateBehavior),

View File

@ -116,6 +116,8 @@ export const DEBUG_STOP_REASONS = [
"removed",
] as const;
export type DebugStopReason = (typeof DEBUG_STOP_REASONS)[number];
export const STEP_MODES = ["into", "over", "out"] as const;
export type StepMode = (typeof STEP_MODES)[number];
export interface SourceBreakpoint {
line: number;
instruction_pointers: bigint[];
@ -132,7 +134,12 @@ export type Command =
}
| {
operation: "step_source";
parameters: { actor_ref: string; implementation_id: string; instruction_budget: number };
parameters: {
actor_ref: string;
implementation_id: string;
instruction_budget: number;
mode?: StepMode;
};
}
| { operation: "inspect_execution"; parameters: { actor_ref: string } }
| { operation: "check_source"; parameters: { source: string } }

View File

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { once } from "node:events";
import test from "node:test";
import { ImageClient, parseEndpoint } from "../src/client";
import { MAX_MESSAGE_BYTES, ProtocolError } from "../src/protocol";
import { MAX_MESSAGE_BYTES, ProtocolError, STEP_MODES } from "../src/protocol";
import { actor, fakeImage, json, notification, REF, response } from "./fixtures";
test("only numeric loopback endpoints and valid ports are accepted", () => {
@ -25,6 +25,21 @@ test("only numeric loopback endpoints and valid ports are accepted", () => {
}
});
test("source step modes are sent explicitly and retain the default into behavior", async (t) => {
const modes: unknown[] = [];
const address = await fakeImage(t, (request, socket) => {
assert.equal(request.command.operation, "step_source");
modes.push(request.command.parameters?.mode);
socket.send(response(request, { kind: "debug_step_completed", data: { reason: "step" } }));
});
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
t.after(() => client.close());
assert.equal(await client.stepSource(REF, "impl"), "step");
for (const mode of STEP_MODES)
assert.equal(await client.stepSource(REF, "impl", 100, mode), "step");
assert.deepEqual(modes, ["into", "into", "over", "out"]);
});
test("debugger arguments reject invalid budgets, identities and line lists before transport", async (t) => {
let requests = 0;
const address = await fakeImage(t, (request, socket) => {