Skip to content

Implement conditional breakpoints#1064

Open
lionel- wants to merge 6 commits intomainfrom
feature/conditional-breakpoints
Open

Implement conditional breakpoints#1064
lionel- wants to merge 6 commits intomainfrom
feature/conditional-breakpoints

Conversation

@lionel-
Copy link
Contributor

@lionel- lionel- commented Feb 27, 2026

Adds support for conditional breakpoints in the DAP server. When a breakpoint has a condition expression, it is evaluated at hit time in the breakpoint's local environment. The breakpoint only stops execution if the condition is truthy.

The condition is stored as a string in the Rust Breakpoint struct and evaluated on the R thread via a new ps_should_break registered function that combines the enabled check with condition evaluation. The DAP mutex is dropped before calling back into R to avoid deadlock.

Condition results are coerced via as.logical() so R's standard truthy/falsy rules apply: 0 skips, 1 stops, nrow(df) works naturally. If evaluation errors or the result can't be coerced to scalar logical (e.g. an environment), the breakpoint fires to avoid silently swallowing typos in conditions. From a quick search this seems standard among DAPs (e.g. js/ts, python). If we feel strongly that we should be stricter, we can revert the last commit.

Conditions that fail with an error always trigger the breakpoint to avoid silently skipping them. The error is logged in the console.The message is fenced with a breakpoint header that includes a link to the breakpoint location:

Screenshot 2026-03-04 at 15 45 49 Screenshot 2026-03-04 at 18 33 47

If there is any output, warning, or messages, we log in the console too.

Screenshot 2026-03-04 at 15 39 57

QA Notes

Comprehensively tested on the backend side (10 integration tests covering true/false conditions, loop iteration, local variables, error conditions, numeric coercion, mixed breakpoints, and live condition updates).

To test manually:

foo <- function(x) {
  y <- x * 2
  y + 1
}
for (i in 1:5) {
  foo(i)
}
  1. Set a conditional breakpoint on y <- x * 2 with condition x == 3. Run the loop, should stop only when x is 3.
  2. Edit the condition to x > 4 without re-sourcing. Run the loop again, should now stop at x == 5.
  3. Try a numeric condition like x - 2 (should skip when x == 2 since 0 is falsy).
  4. Try a broken condition like nonexistent — should stop on every hit rather than silently skipping. You should see the error in the Console with a link to jump to the breakpoint.
  5. Output, warnings, and messages emitted by the breakpoint conditions are logged in the Console.

@lionel- lionel- force-pushed the feature/conditional-breakpoints branch from 10a6f4f to 04a5d55 Compare March 4, 2026 17:35
@lionel- lionel- requested a review from DavisVaughan March 4, 2026 17:42
Copy link
Contributor

@DavisVaughan DavisVaughan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried it out on case_when() and it seems to work pretty nicely!

Comment on lines +1014 to +1020
pub(crate) fn with_capture<T>(f: impl FnOnce() -> T) -> (T, String) {
let mut capture = Console::get_mut().start_capture();
let result = f();
let output = capture.take();
drop(capture);
(result, output)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub(crate) fn with_capture<T>(f: impl FnOnce() -> T) -> (T, String) {
let mut capture = Console::get_mut().start_capture();
let result = f();
let output = capture.take();
drop(capture);
(result, output)
}
pub(crate) fn with_capture<T>(f: impl FnOnce() -> T) -> (T, String) {
let mut capture = Console::get_mut().start_capture();
let result = f();
let output = capture.take();
(result, output)
}

That seems a bit superfluous?

Comment on lines +649 to +650
#[cfg(test)]
mod tests_condition_output {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move tests to the very bottom? i totally skipped over eval_condition() because i thought this was the end of the file

Comment on lines +747 to +749
// `if` coerces via `asLogicalNoNA` (not the generic `as.logical`)
// and errors on NA, length != 1, and non-coercible types.
let code = format!("base::.ark_eval_capture(if ({{ {condition} }}) TRUE else FALSE)");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like the right semantics to me

Comment on lines +770 to +771
Ok(val) => (val, conditions, None),
Err(err) => (true, conditions, Some(format!("Error: {err}"))),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had wondered what this mysterious 3rd diagnostic field was when reading the rest of the code.

...it's just what we get when a conversion from an R bool to Rust bool fails?

That feels like maybe a bit of overkill? Can we just tie that in with conditions or something instead?

I think it would have cleared things up for me quite a bit while reading the rest of the code.

@DavisVaughan
Copy link
Contributor

Also I agree that stopping on errors feels good! And I like the console output idea

Screenshot 2026-03-05 at 1 47 30 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants