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:
parent
19eaeb447c
commit
68afb435f3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<()>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue