diff --git a/CHANGELOG.md b/CHANGELOG.md index d591f63..589d1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5c586e5..65f9940 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/runa-core/src/execution.rs b/crates/runa-core/src/execution.rs index 08043f5..6530fa2 100644 --- a/crates/runa-core/src/execution.rs +++ b/crates/runa-core/src/execution.rs @@ -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, diff --git a/crates/runa-core/src/lib.rs b/crates/runa-core/src/lib.rs index 05109c7..0dd51b4 100644 --- a/crates/runa-core/src/lib.rs +++ b/crates/runa-core/src/lib.rs @@ -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}; diff --git a/crates/runa-image-api/src/lib.rs b/crates/runa-image-api/src/lib.rs index ea9438a..bcb0607 100644 --- a/crates/runa-image-api/src/lib.rs +++ b/crates/runa-image-api/src/lib.rs @@ -382,7 +382,17 @@ impl ImageApi { expected: ImplementationId, budget: NonZeroUsize, ) -> RuntimeResult { - 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 { + let result = self.runtime.step_source_mode(actor, expected, budget, mode); self.collect_runtime_events(); result } diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index 3a0b097..0444ac6 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -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::(wire.clone()).is_err()); + } + } + #[test] fn request_uses_a_stable_tagged_wire_shape() { let value = serde_json::to_value(request(Command::Send { diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index f874309..88e80c2 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -228,6 +228,16 @@ impl Runtime { id: ActorRef, expected: ImplementationId, budget: NonZeroUsize, + ) -> ExperimentResult { + 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 { 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| { - stack.call_depth != origin.call_depth || Some(line) != origin_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<()> { diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index b32d986..bdce0c4 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index afee282..984b1ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/protocol.md b/docs/protocol.md index b8c4c2f..6e3a47b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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; diff --git a/docs/runtime.md b/docs/runtime.md index 26b4e51..1c2a611 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 diff --git a/vscode/README.md b/vscode/README.md index e8dc5da..b82e3dc 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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. diff --git a/vscode/integration/debugger.test.ts b/vscode/integration/debugger.test.ts index da53f46..4e483e8 100644 --- a/vscode/integration/debugger.test.ts +++ b/vscode/integration/debugger.test.ts @@ -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 }, diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 989fa28..6c81336 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -344,7 +344,7 @@ async function sourceDebugging(address: string): Promise { 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 { 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 { 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(); diff --git a/vscode/package.json b/vscode/package.json index 42ee4c6..ca9d11b 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/src/client.ts b/vscode/src/client.ts index d83481f..e11edda 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -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 { 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"); diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 5fe9d34..964cac1 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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), diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index 51dfe1c..745f115 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -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 } } diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts index 353e6cf..4ce5bf4 100644 --- a/vscode/test/client.test.ts +++ b/vscode/test/client.test.ts @@ -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) => {