Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 47 additions & 11 deletions bindings/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

#include "openscad_cpp_parser/api.hpp"

#include <atomic>
#include <cstdint>
#include <exception>
#include <memory>
Expand Down Expand Up @@ -362,17 +363,20 @@ using GetChildrenPositionsFn = std::function<const std::optional<std::vector<std
// exactly where it already computes should_pause, without needing to
// widen hook()'s existing (cmd, mods) return-tuple contract -- mirrors
// generate_partial/get_children_positions's own "extra kwarg the Python
// side may or may not call" shape.
using SetFastContinueFn = std::function<void(std::optional<std::unordered_map<std::string, std::set<int>>>)>;
// 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<void(std::optional<std::unordered_map<std::string, std::set<int>>>, 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<std::string, std::set<int>> bp;
Expand All @@ -381,7 +385,7 @@ nb::object setFastContinueTrampoline(const SetFastContinueFn& setFastContinue) {
for (nb::handle line : v) lines.insert(nb::cast<int>(line));
bp.emplace(nb::cast<std::string>(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
Expand All @@ -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<std::atomic<bool>>& flag() const { return flag_; }

private:
std::shared_ptr<std::atomic<bool>> flag_ = std::make_shared<std::atomic<bool>>(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.
Expand Down Expand Up @@ -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<oscadeval::ManifoldCache> manifoldCache, nb::object returnHook) {
std::shared_ptr<oscadeval::ManifoldCache> manifoldCache, nb::object returnHook,
FastContinueSignal* fastContinueSignal) {
std::unordered_map<std::string, oscadeval::Value> vp = toViewportParams(viewportParams);

std::vector<oscadeval::ColoredBody> bodies;
Expand Down Expand Up @@ -501,8 +530,8 @@ nb::object debugEvaluate(const std::string& path, nb::dict viewportParams, nb::c
return evPtr->lastChildrenPositions();
};
SetFastContinueFn setFastContinue =
[&evPtr](std::optional<std::unordered_map<std::string, std::set<int>>> breakpoints) {
evPtr->setFastContinueBreakpoints(std::move(breakpoints));
[&evPtr](std::optional<std::unordered_map<std::string, std::set<int>>> 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,
Expand All @@ -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);
Expand Down Expand Up @@ -602,13 +632,19 @@ 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_<FastContinueSignal>(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).");
m.def("parse_decls", &parseDecls, nb::arg("path"),
"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.");
}
70 changes: 65 additions & 5 deletions include/openscad_cpp_evaluator/evaluator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

#include "openscad_cpp_parser/ast.hpp"

#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
Expand Down Expand Up @@ -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<std::unordered_map<std::string, std::set<int>>> 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<std::unordered_map<std::string, std::set<int>>> 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<atomic<bool>> -- 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<std::atomic<bool>> flag) {
fastContinueInterrupt_ = std::move(flag);
}

// Checks whether a debug pause should happen at `node` (via the
Expand Down Expand Up @@ -952,6 +1002,16 @@ class Evaluator {
// setter leaves it at) means "no fast-continue exception" -- exactly
// today's original, always-safe behavior.
std::optional<std::unordered_map<std::string, std::set<int>>> 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<std::atomic<bool>> 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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 15 additions & 3 deletions python/openscad_cpp_evaluator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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
Expand All @@ -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 = {}
Expand All @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions src/debug_profile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ std::vector<DebugFrame> 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<int>(callStack_.size());
const DebugFramesFn getFrame = [this, &ctx]() { return buildDebugFrames(&ctx); };
lastChildrenPositions_ = childStatementPositions(node);
Expand Down
Loading