From 45d09743968e4c5d817a65e4efe93911bcf81a85 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Thu, 30 Jul 2026 02:00:34 -0700 Subject: [PATCH] Skip checkDebug()'s Python round-trip entirely in fast-continue mode Found via a real BelfrySCAD bug report: debugging a NURBS-surface script and immediately clicking Continue took ~2x longer than a plain render, even with per-function fast-continue (existing feature) already engaged. Measured root cause: 462,094 checkDebug() calls still crossed into Python even with fast-continue fully applied -- module/ geometry evaluation never compiles (a deliberate, separate system, see docs elsewhere), so every statement there still called into Python just to be told "continue". Disabling fast-continue entirely made this 5.99 million calls -- confirming those calls WERE genuinely being helped, just not eliminated. Extends the same mechanism to checkDebug() itself, not just VM eligibility: setFastContinueBreakpoints gains a second `hookSkippable` parameter (true only for a plain "Continue" with no step pending -- step_over/step_out still need every statement inspected for their own step_hit logic, so they keep hookSkippable=false even though they also pass a real breakpoints set). checkDebug() then skips the whole Python call (and the childStatementPositions/getFrame setup before it) for any line with no breakpoint, in that mode. Since debug_evaluate() runs as one single blocking call with the GIL released for its duration, there's no live Python-callable Evaluator handle DebugSession.pause()/set_breakpoints() (main thread) could otherwise interrupt directly. Adds FastContinueSignal: a small, lock-free, GIL-free shared flag a caller creates once per debug session and calls .request() on to guarantee the very next checkDebug() call isn't skipped, regardless of whether it hits a breakpoint -- checkDebug() test-and-clears it atomically. breakpoint() (forced=true) always bypasses the skip, unaffected. New tests in test_debug_hooks.cpp cover: skip with no matching breakpoint, still-fires at a matching one, unaffected when not hook-skippable (step_over/out), the interrupt flag forcing a call through, and breakpoint()'s own bypass. Full 1297-test suite passes; verified end-to-end via the actual Python binding (20,007 -> 1 hook call for a representative script). Co-Authored-By: Claude Sonnet 5 --- bindings/module.cpp | 58 +++++++-- include/openscad_cpp_evaluator/evaluator.hpp | 70 ++++++++++- pyproject.toml | 2 +- python/openscad_cpp_evaluator/__init__.py | 18 ++- src/debug_profile.cpp | 30 +++++ tests/test_debug_hooks.cpp | 120 +++++++++++++++++++ 6 files changed, 278 insertions(+), 20 deletions(-) diff --git a/bindings/module.cpp b/bindings/module.cpp index c868108..bc65be7 100644 --- a/bindings/module.cpp +++ b/bindings/module.cpp @@ -27,6 +27,7 @@ #include "openscad_cpp_parser/api.hpp" +#include #include #include #include @@ -362,17 +363,20 @@ using GetChildrenPositionsFn = std::function>>)>; +// side may or may not call" shape. `hookSkippable` mirrors +// setFastContinueBreakpoints's own second parameter exactly -- see its +// doc comment for why this is narrower than "breakpoints is accurate." +using SetFastContinueFn = std::function>>, bool)>; // Wraps a SetFastContinueFn as a Python callable taking either a // dict[str, set[int]]/dict[str, list[int]] (origin -> breakpoint lines) or -// None. Mirrors generatePartialTrampoline's own shape. +// None, plus a hook_skippable bool (default False). Mirrors +// generatePartialTrampoline's own shape. nb::object setFastContinueTrampoline(const SetFastContinueFn& setFastContinue) { return nb::cpp_function( - [&setFastContinue](nb::object breakpoints) { + [&setFastContinue](nb::object breakpoints, bool hookSkippable) { if (breakpoints.is_none()) { - setFastContinue(std::nullopt); + setFastContinue(std::nullopt, hookSkippable); return; } std::unordered_map> bp; @@ -381,7 +385,7 @@ nb::object setFastContinueTrampoline(const SetFastContinueFn& setFastContinue) { for (nb::handle line : v) lines.insert(nb::cast(line)); bp.emplace(nb::cast(k), std::move(lines)); } - setFastContinue(std::move(bp)); + setFastContinue(std::move(bp), hookSkippable); }, // Without .none(), nanobind rejects a Python `None` argument for an // `nb::object`-typed parameter registered this ad-hoc way (unlike a @@ -391,9 +395,33 @@ nb::object setFastContinueTrampoline(const SetFastContinueFn& setFastContinue) { // was called with breakpoints=None (DebugSession's own // _apply_fast_continue calls it with None whenever fast-continue // mode isn't safe right now -- see debugger.py). - nb::arg("breakpoints").none()); + nb::arg("breakpoints").none(), nb::arg("hook_skippable") = false); } +// Python-visible handle onto a lock-free, GIL-free interrupt flag -- see +// Evaluator::setFastContinueInterruptFlag's own doc comment (evaluator.hpp) +// for why this exists at all: debug_evaluate() runs as one single blocking +// call with the GIL released for its whole duration, so there is no live, +// Python-callable Evaluator handle DebugSession.pause()/set_breakpoints() +// (running on the MAIN/GUI thread) could otherwise invoke directly to +// interrupt hook-skippable mode. A caller creates ONE of these before +// calling debug_evaluate() (passing it as fast_continue_signal), keeps it +// around for the whole debug session, and calls .request() from the main +// thread any time a hook-skippable checkDebug() call needs to stop +// skipping and actually consult Python again -- Pause, or a breakpoint +// being toggled in an editor tab while a render is mid-flight. Nothing +// else needs to read it back on the Python side; C++ test-and-clears it +// (see checkDebug's own doc comment, debug_profile.cpp) the moment it acts +// on it, so there's no separate "acknowledge" step. +class FastContinueSignal { +public: + void request() { flag_->store(true, std::memory_order_release); } + const std::shared_ptr>& flag() const { return flag_; } + +private: + std::shared_ptr> flag_ = std::make_shared>(false); +}; + // GIL held (reacquired by the caller). `getFrame`/`callStack`/`generatePartial`/ // `getChildrenPositions` are valid only for this synchronous call, so their // closures are only ever invoked from within the Python hook, before it returns. @@ -470,7 +498,8 @@ void callPyReturnHook(nb::handle returnHook, const std::string& name, const osca // blocks on the Python side (threading.Event) so the GUI thread keeps running. nb::object debugEvaluate(const std::string& path, nb::dict viewportParams, nb::callable debugHook, nb::callable errorBreak, nb::callable echoFn, - std::shared_ptr manifoldCache, nb::object returnHook) { + std::shared_ptr manifoldCache, nb::object returnHook, + FastContinueSignal* fastContinueSignal) { std::unordered_map vp = toViewportParams(viewportParams); std::vector bodies; @@ -501,8 +530,8 @@ nb::object debugEvaluate(const std::string& path, nb::dict viewportParams, nb::c return evPtr->lastChildrenPositions(); }; SetFastContinueFn setFastContinue = - [&evPtr](std::optional>> breakpoints) { - evPtr->setFastContinueBreakpoints(std::move(breakpoints)); + [&evPtr](std::optional>> breakpoints, bool hookSkippable) { + evPtr->setFastContinueBreakpoints(std::move(breakpoints), hookSkippable); }; oscadeval::DebugHooks hooks; hooks.debugHook = [&](int line, int depth, bool forced, bool exprLevel, const std::string& origin, @@ -528,6 +557,7 @@ nb::object debugEvaluate(const std::string& path, nb::dict viewportParams, nb::c oscadeval::ResolvedUseScopes used = oscadeval::resolveUseScopes(ast, path, echoCpp); oscadeval::Evaluator ev(echoCpp, nullptr, manifoldCache, hooks, false); evPtr = &ev; + if (fastContinueSignal) ev.setFastContinueInterruptFlag(fastContinueSignal->flag()); oscadeval::EvalContext ctx = oscadeval::EvalContext::makeRoot(used.rootScope.get()); bodies = oscadeval::toRenderableBodies(ev.evaluate(used.processedNodes, ctx, vp)); collectIdSpans(ev, idSpans); @@ -602,6 +632,12 @@ NB_MODULE(_openscad_cpp_evaluator, m) { .def(nb::init<>()) .def("clear", &oscadeval::ManifoldCache::clear); + // See FastContinueSignal's own doc comment, above -- only a + // constructor and request() are ever called from Python. + nb::class_(m, "FastContinueSignal") + .def(nb::init<>()) + .def("request", &FastContinueSignal::request); + m.def("evaluate", &evaluate, nb::arg("path"), nb::arg("viewport_params"), nb::arg("manifold_cache") = nullptr, nb::arg("profile") = false, "Evaluate a .scad file; return (bodies, echoes, id_to_node, csg_tree, profile_result, dyn, dyn_explicit)."); @@ -609,6 +645,6 @@ NB_MODULE(_openscad_cpp_evaluator, m) { "Parse a .scad file; return top-level declaration (namespace, name, start, end, line, column, origin) tuples."); m.def("debug_evaluate", &debugEvaluate, nb::arg("path"), nb::arg("viewport_params"), nb::arg("debug_hook"), nb::arg("error_break"), nb::arg("echo_fn"), nb::arg("manifold_cache") = nullptr, - nb::arg("return_hook") = nb::none(), + nb::arg("return_hook") = nb::none(), nb::arg("fast_continue_signal") = nullptr, "Evaluate with the debugger wired in; returns (bodies, [], id_to_node, dyn, dyn_explicit). Callbacks fire under the GIL."); } diff --git a/include/openscad_cpp_evaluator/evaluator.hpp b/include/openscad_cpp_evaluator/evaluator.hpp index 94edda9..f8584c7 100644 --- a/include/openscad_cpp_evaluator/evaluator.hpp +++ b/include/openscad_cpp_evaluator/evaluator.hpp @@ -15,6 +15,7 @@ #include "openscad_cpp_parser/ast.hpp" +#include #include #include #include @@ -329,12 +330,61 @@ class Evaluator { // exception at all" (today's original, always-safe behavior) -- // e.g. the instant a step command starts or a pause is requested. // Cheap to call as often as the debugger's own state changes: this - // just updates one member, no cache invalidation needed (chunkEligibleNow - // is re-checked on every lookupOrCompileChunk/lookupCompiledLiteralChunk - // call, not cached itself -- only the compiled bytecode ITSELF, which - // never depends on debugger state, is cached forever). - void setFastContinueBreakpoints(std::optional>> breakpoints) { + // just updates two members, no cache invalidation needed (chunkEligibleNow/ + // checkDebug are re-checked on every call, not cached themselves -- only + // the compiled bytecode ITSELF, which never depends on debugger state, + // is cached forever). + // + // `hookSkippable`: a STRICTLY narrower claim than "breakpoints is + // accurate" above -- true only when NOTHING needs to inspect a + // statement-level checkpoint's own line/depth at all, not just "nothing + // needs to inspect it unless it's a compiled function's entry." This is + // false for step_over/step_out even though THEY also pass a real + // breakpoints set (chunkEligibleNow/useBytecodeVm still apply for them): + // both need checkDebug() to keep calling into the debug hook on every + // statement so the caller's own step_hit logic (line/depth comparison + // against the step's own starting point) can run -- there is no way to + // decide that in advance the way a breakpoint LOCATION can be. Only a + // plain "Continue" with no step pending can safely skip the call + // entirely for a line with no breakpoint -- see checkDebug's own doc + // comment (debug_profile.cpp) for where this is actually consulted. + void setFastContinueBreakpoints(std::optional>> breakpoints, + bool hookSkippable = false) { fastContinueBreakpoints_ = std::move(breakpoints); + fastContinueHookSkippable_ = hookSkippable; + } + + // The other half of hook-skippable mode's safety net. checkDebug()'s + // whole premise (see its own doc comment, debug_profile.cpp) is that it + // can skip calling into Python for a line with no breakpoint -- but the + // caller that decided that (DebugSession, on the MAIN/GUI thread) needs + // a way to say "actually, don't skip the very next one" from OUTSIDE any + // hook call, since debug_evaluate() runs as one single blocking call + // with the GIL released for its whole duration: there is no live, + // Python-callable Evaluator handle to invoke setFastContinueBreakpoints + // on directly the way a synchronous API would allow. A user clicking + // Pause, or toggling a breakpoint in an editor tab, while a hook- + // skippable render is mid-flight needs to take effect on the very next + // checkpoint, not "whenever a breakpoint happens to be hit next" (which, + // in hook-skippable mode, could be never, for a script with none set). + // + // `flag` is a plain shared_ptr> -- lock-free and GIL-free + // by construction, so the caller (bindings/module.cpp wraps it in a + // small Python-visible class) can set it from the main thread at any + // moment with no synchronization needed beyond the atomic itself. + // checkDebug() atomically test-and-clears it (exchange) on every call + // that would otherwise skip: if it was set, this call falls through and + // actually invokes the Python hook instead, which re-derives and pushes + // fresh breakpoints/hookSkippable state via its own existing logic -- + // clearing it here (not from Python) means there's no separate + // "acknowledge" round-trip needed. Never itself compared against + // anything else; nullptr (the default, and what a plain debugger + // attach that never wires this up leaves it at) simply means hook- + // skippable mode -- if ever engaged at all -- can't be interrupted this + // way, which is only actually reachable if a caller opts into + // hookSkippable=true above without also providing this. + void setFastContinueInterruptFlag(std::shared_ptr> flag) { + fastContinueInterrupt_ = std::move(flag); } // Checks whether a debug pause should happen at `node` (via the @@ -952,6 +1002,16 @@ class Evaluator { // setter leaves it at) means "no fast-continue exception" -- exactly // today's original, always-safe behavior. std::optional>> fastContinueBreakpoints_; + // See setFastContinueBreakpoints's own doc comment for why this is a + // separate, narrower flag from fastContinueBreakpoints_ itself -- false + // (the default) whenever a debugger is attached without explicitly + // opting in, matching today's always-safe "checkDebug always calls the + // hook" behavior. + bool fastContinueHookSkippable_ = false; + // See setFastContinueInterruptFlag's own doc comment. nullptr (the + // default) means hook-skippable mode, if ever engaged, can't be + // interrupted from outside a hook call. + std::shared_ptr> fastContinueInterrupt_; // The root EvalContext resolveTree() was called with -- lets // checkDebug()'s locals snapshot fall back to top-level script // variables when paused inside a nested user call, the same way the diff --git a/pyproject.toml b/pyproject.toml index 4c8b532..68f73a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.6.1" +version = "0.7.0" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/python/openscad_cpp_evaluator/__init__.py b/python/openscad_cpp_evaluator/__init__.py index cc9db6a..65f6135 100644 --- a/python/openscad_cpp_evaluator/__init__.py +++ b/python/openscad_cpp_evaluator/__init__.py @@ -13,11 +13,12 @@ from typing import Optional from . import _openscad_cpp_evaluator as _ext -from ._openscad_cpp_evaluator import ManifoldCache +from ._openscad_cpp_evaluator import FastContinueSignal, ManifoldCache __all__ = [ "Evaluator", "ColoredBody", "EvalError", "ParseError", "OscObject", "parse", "to_renderable_bodies", "ManifoldCache", "CallSiteProfile", "ProfileResult", "format_csg_tree", "bodies_from_dicts", + "FastContinueSignal", ] @@ -317,6 +318,16 @@ class Evaluator: `return_hook`: fires after a user function/function-literal call computes its result, before returning -- (name, value, depth). Only meaningful with debug_hook set (the plain evaluate() path never calls it). + `fast_continue_signal`: an optional FastContinueSignal the caller keeps + across the whole debug session and calls `.request()` on (e.g. from + DebugSession.pause()/set_breakpoints(), BelfrySCAD's own debugger.py) + any time a checkDebug() checkpoint skipped via hook-skippable fast- + continue mode (see debug_hook's own `set_fast_continue(breakpoints, + hook_skippable)` kwarg) needs to stop skipping and consult Python + again -- there is no other way to reach a running debug_evaluate() call + from outside a hook invocation, since it runs as one single blocking + call with the GIL released for its duration. Only meaningful with + debug_hook set; ignored otherwise. After evaluate() returns, `self.csg_tree` (list of _CSGNode, see format_csg_tree), `self.dyn` (dict of every currently-visible @@ -328,13 +339,14 @@ class Evaluator: """ def __init__(self, echo_fn=None, debug_hook=None, error_break_fn=None, return_hook=None, - manifold_cache=None, profile=False): + manifold_cache=None, profile=False, fast_continue_signal=None): self._echo_fn = echo_fn self._debug_hook = debug_hook self._error_break_fn = error_break_fn self._return_hook = return_hook self._manifold_cache = manifold_cache self._profile = profile + self._fast_continue_signal = fast_continue_signal self.csg_tree = [] self.profile_result = None self.dyn = {} @@ -350,7 +362,7 @@ def evaluate(self, source_path: str, viewport_params: Optional[dict] = None): source_path, vp, self._debug_hook, self._error_break_fn or (lambda *a, **k: None), self._echo_fn or (lambda _m: None), - self._manifold_cache, self._return_hook) + self._manifold_cache, self._return_hook, self._fast_continue_signal) self.csg_tree = [] self.profile_result = None else: diff --git a/src/debug_profile.cpp b/src/debug_profile.cpp index 834f563..f73cf91 100644 --- a/src/debug_profile.cpp +++ b/src/debug_profile.cpp @@ -77,6 +77,36 @@ std::vector Evaluator::buildDebugFrames(const EvalContext* ctx) cons void Evaluator::checkDebug(const oscad::ASTNode& node, EvalContext& ctx, bool forced, bool exprLevel) { if (!debugHooks_.debugHook) return; const oscad::Position& pos = node.position(); + // Fast-continue's hook-skippable mode (setFastContinueBreakpoints' own + // doc comment): a plain "Continue" with no step pending needs the debug + // hook called ONLY for a line that actually has a breakpoint -- every + // other checkpoint is guaranteed to do nothing (no forced/breakpoint/ + // step_hit condition on the Python side can possibly fire), so skip the + // call (and the childStatementPositions/getFrame setup below, all + // wasted work otherwise) entirely rather than crossing into Python just + // to be told "continue". `forced` (the explicit breakpoint() builtin) + // always bypasses this, matching its own "bypasses nothing" contract. + // Never applies to step_over/step_out (hookSkippable is false for those + // even though they also set a real breakpoints set, see the setter's + // own doc comment) or step_into/step_to_child (fastContinueBreakpoints_ + // itself is nullopt then) -- both need every statement inspected. + if (!forced && fastContinueHookSkippable_ && fastContinueBreakpoints_) { + // Test-and-clear: if the main thread requested an interrupt (Pause, + // or a breakpoint edit -- see setFastContinueInterruptFlag's own doc + // comment) since the last checkDebug() call, this call falls + // through and actually invokes the hook below instead of skipping, + // regardless of whether THIS specific line has a breakpoint -- + // that's what lets the hook's own logic (pause_now, or a freshly + // updated breakpoints dict) run at all in hook-skippable mode. + const bool interrupted = + fastContinueInterrupt_ && fastContinueInterrupt_->exchange(false, std::memory_order_acq_rel); + if (!interrupted) { + auto originIt = fastContinueBreakpoints_->find(pos.origin); + if (originIt == fastContinueBreakpoints_->end() || !originIt->second.count(pos.line)) { + return; + } + } + } const int depth = static_cast(callStack_.size()); const DebugFramesFn getFrame = [this, &ctx]() { return buildDebugFrames(&ctx); }; lastChildrenPositions_ = childStatementPositions(node); diff --git a/tests/test_debug_hooks.cpp b/tests/test_debug_hooks.cpp index 51eaa46..d20503e 100644 --- a/tests/test_debug_hooks.cpp +++ b/tests/test_debug_hooks.cpp @@ -6,8 +6,12 @@ #include #include +#include +#include #include +#include #include +#include #include using namespace oscadeval; @@ -230,6 +234,122 @@ TEST(DebugHooks, NoHooksInstalledMeansZeroOverheadCodePathStillWorks) { EXPECT_EQ(e.bodies.size(), 1u); } +// --------------------------------------------------------------------------- +// Fast-continue's hook-skippable mode (issue found via BelfrySCAD's own +// nurbs.scad-heavy debug session: 462k checkDebug() calls survived even +// with per-function VM fast-continue fully engaged, since module/geometry +// evaluation never compiles and every one of those still crossed into +// Python just to be told "continue"). See setFastContinueBreakpoints's own +// doc comment (evaluator.hpp) for the full contract. +// --------------------------------------------------------------------------- + +TEST(DebugHooks, FastContinueHookSkippableSkipsCheckpointsWithNoMatchingBreakpoint) { + int calls = 0; + DebugHooks hooks; + hooks.debugHook = [&](int, int, bool, bool, const std::string&, const std::vector&, const DebugFramesFn&) { + ++calls; + return DebugAction{}; + }; + Evaluator ev(EchoFn{}, nullptr, nullptr, hooks); + ev.setFastContinueBreakpoints(std::unordered_map>{}, /*hookSkippable=*/true); + auto ast = parseSrc("cube(1);\ntranslate([1,0,0]) sphere(r=1,$fn=8);\necho(\"x\");"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.evaluate(ast, ctx); + // Same script as HookFiresOnceForEachStatementAtEveryNestingLevel (7 + // checkpoints without fast-continue) -- with hook-skippable mode and no + // breakpoints anywhere, every single one is a plain C++ skip. + EXPECT_EQ(calls, 0); +} + +TEST(DebugHooks, FastContinueHookSkippableStillFiresAtMatchingBreakpointLine) { + std::vector firedLines; + DebugHooks hooks; + hooks.debugHook = [&](int line, int, bool, bool, const std::string&, const std::vector&, const DebugFramesFn&) { + firedLines.push_back(line); + return DebugAction{}; + }; + Evaluator ev(EchoFn{}, nullptr, nullptr, hooks); + ev.setFastContinueBreakpoints(std::unordered_map>{{"", {2}}}, /*hookSkippable=*/true); + auto ast = parseSrc("cube(1);\ntranslate([1,0,0]) sphere(r=1,$fn=8);\necho(\"x\");"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.evaluate(ast, ctx); + // Every line-2 checkpoint (translate itself, its 3 expr-level [1,0,0] + // list elements, and the nested sphere child -- 5 total, matching + // HookFiresOnceForEachStatementAtEveryNestingLevel's own sequence) + // survives the skip; line 1 (cube) and line 3 (echo) have no + // breakpoint and are skipped entirely. + EXPECT_EQ(firedLines, (std::vector{2, 2, 2, 2, 2})); +} + +TEST(DebugHooks, FastContinueNotHookSkippableStillFiresEveryCheckpoint) { + int calls = 0; + DebugHooks hooks; + hooks.debugHook = [&](int, int, bool, bool, const std::string&, const std::vector&, const DebugFramesFn&) { + ++calls; + return DebugAction{}; + }; + Evaluator ev(EchoFn{}, nullptr, nullptr, hooks); + // Mirrors step_over/step_out: a real (here, empty) breakpoints set is + // provided -- so chunkEligibleNow/useBytecodeVm still apply -- but + // hookSkippable stays false, since both need checkDebug() to keep + // calling into the hook on every statement so their own step_hit logic + // (line/depth comparison against the step's own starting point) can + // run; there is no way to decide that in advance. + ev.setFastContinueBreakpoints(std::unordered_map>{}, /*hookSkippable=*/false); + auto ast = parseSrc("cube(1);\ntranslate([1,0,0]) sphere(r=1,$fn=8);\necho(\"x\");"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.evaluate(ast, ctx); + EXPECT_EQ(calls, 7); // unaffected -- same total as HookFiresOnceForEachStatementAtEveryNestingLevel +} + +TEST(DebugHooks, FastContinueInterruptFlagForcesTheNextCheckpointThrough) { + std::vector firedLines; + DebugHooks hooks; + hooks.debugHook = [&](int line, int, bool, bool, const std::string&, const std::vector&, const DebugFramesFn&) { + firedLines.push_back(line); + return DebugAction{}; + }; + Evaluator ev(EchoFn{}, nullptr, nullptr, hooks); + ev.setFastContinueBreakpoints(std::unordered_map>{}, /*hookSkippable=*/true); + // Pre-armed, as if Pause (or a breakpoint edit) was requested from the + // main thread before this run even started -- see + // setFastContinueInterruptFlag's own doc comment (evaluator.hpp) for + // why this exists: there is no other way to reach a running + // debug_evaluate() call from outside a hook invocation. + auto flag = std::make_shared>(true); + ev.setFastContinueInterruptFlag(flag); + auto ast = parseSrc("cube(1);\ntranslate([1,0,0]) sphere(r=1,$fn=8);\necho(\"x\");"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.evaluate(ast, ctx); + // Only the FIRST checkpoint (line 1, cube) fires -- checkDebug's own + // test-and-clear means the flag is consumed there, so every subsequent + // checkpoint goes back to being skipped normally (no breakpoints + // anywhere). + EXPECT_EQ(firedLines, (std::vector{1})); + EXPECT_FALSE(flag->load()); +} + +TEST(DebugHooks, ForcedBreakpointBuiltinAlwaysFiresEvenInHookSkippableMode) { + int forcedCalls = 0, normalCalls = 0; + DebugHooks hooks; + hooks.debugHook = [&](int, int, bool forced, bool, const std::string&, const std::vector&, const DebugFramesFn&) { + (forced ? forcedCalls : normalCalls)++; + return DebugAction{}; + }; + Evaluator ev(EchoFn{}, nullptr, nullptr, hooks); + ev.setFastContinueBreakpoints(std::unordered_map>{}, /*hookSkippable=*/true); + auto ast = parseSrc("cube(1);\nbreakpoint();\nsphere(r=1,$fn=8);"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.evaluate(ast, ctx); + EXPECT_EQ(forcedCalls, 1); // breakpoint() always bypasses hook-skippable mode + EXPECT_EQ(normalCalls, 0); // cube/breakpoint()-as-statement/sphere: no breakpoint line matches, all skipped +} + // --------------------------------------------------------------------------- // Full parity with the Python reference's _check_debug call sites. //