Menu

Aussom Debugger Implementation Guide

This guide is for Java developers who are embedding aussom-base and want to wire up debugging support — a DAP server for an IDE, a stdin debugger client, a custom logging tool, or any other program that needs to observe and control an Aussom script as it runs.

You should already be comfortable with embedding aussom-base through the Engine API. If you aren't, read the standard embedding guide first.

The design document this guide is built on lives at design/debugging-interface-design.md. That document is the authoritative reference. This guide is the practical walkthrough.


1. What it is

The debugging interface is a small Java surface on Engine that lets your code observe and control an Aussom script as the interpreter walks the AST. You implement the DebuggerInt interface, register your implementation with the engine, and the interpreter calls into your code at well-defined points:

  • When a node marked with a breakpoint is about to be evaluated.
  • Once per eval, to ask whether to pause for a step.
  • When an exception flows out of an eval (either as a value or as a thrown Java exception).

You decide what to do at each event: notify a UI, block the thread, inspect locals, run an expression in the paused frame, or just record it. The engine stays out of policy — it provides the hooks and the data; you provide the behavior.

The interface is general enough to build a full DAP server on top of it (the moving parts that DAP needs — stack traces, locals, evaluate-in-frame, step over/into/out, pause from outside, exception breakpoints — are all reachable). It is also small enough to build a single-purpose tool against in a few hundred lines.


2. The big picture

The pieces you work with:

Type What it is
com.aussom.DebuggerInt Interface you implement. Four methods.
com.aussom.PauseReason Enum passed to onPause (BREAKPOINT, STEP).
com.aussom.AussomDebuggingInt Engine-side control surface. Implemented by Engine. Use this type for cross-embedder code.
Engine.setDebugger / getDebugger / isDebugMode Registration and gating.
Engine.findNodesByLine Maps (file, line) -> a list of AST nodes you can mark.
Engine.setBreakpoint / clearBreakpoint / clearAllBreakpoints Convenience helpers for breakpoint management.
Engine.evalInFrame Evaluates a snippet against a paused frame's environment.
astNode.breakpoint Public volatile boolean on every AST node. Set by you.
AussomException.isDebuggerSeen Dedup flag the engine sets on value-form exceptions.

About AussomDebuggingInt: if your code might run inside different embedders of aussom-base (the Aussom CLI, the Aussom Server, a custom host you're building), type your references to the engine's debugger surface as AussomDebuggingInt rather than Engine. Each embedder exposes the same surface; your code (especially a shared DAP server implementation) becomes portable across all of them. Engine implements the interface directly, so passing an Engine as AussomDebuggingInt is a no-cost type change.

The lifecycle in one paragraph:

You construct an Engine, then call setDebugger(yourImpl) before any interpreter thread starts running. You parse Aussom source as usual. You use findNodesByLine to find the AST nodes for your breakpoints, set their breakpoint field to true, then call eng.run() (or any other entry that starts evaluating). When a node hits the breakpoint, the engine calls yourImpl.onPause(node, env, BREAKPOINT). Your code typically blocks the thread on a semaphore until the user clicks "continue" or "step", then returns.

The interpreter has exactly one hook point: at the top of every call to astNode.eval. Gated on isDebugMode(). In production (debug off) the hook is a single boolean check.


3. Quick start

A complete minimal example. This debugger pauses on every breakpoint, prints "paused" with the line number, then resumes on a key press. Replace the System.in read with whatever your real UI does.

import com.aussom.DebuggerInt;
import com.aussom.DefaultSecurityManagerImpl;
import com.aussom.Engine;
import com.aussom.Environment;
import com.aussom.PauseReason;
import com.aussom.ast.astNode;
import com.aussom.ast.aussomException;
import com.aussom.types.AussomException;

public class HelloDebugger implements DebuggerInt {

    @Override
    public void onPause(astNode node, Environment env, PauseReason reason)
            throws aussomException {
        System.out.println("[paused] " + reason
            + " at " + node.getFileName() + ":" + node.getLineNum());
        System.out.println("press enter to continue...");
        try { System.in.read(); }
        catch (Exception e) { /* ignore */ }
    }

    @Override
    public boolean shouldPauseForStep(astNode node, Environment env) {
        return false;     // no stepping in this example
    }

    @Override
    public void onException(AussomException ex, Environment env) {
        System.out.println("[aussom exception] " + ex.getText());
    }

    @Override
    public void onException(Exception ex, Environment env) {
        System.out.println("[thrown] " + ex.getClass().getSimpleName()
            + ": " + ex.getMessage());
    }

    /**
     * Default security denies setDebugger. Subclass it and flip
     * the gate. See section 4.1 for details.
     */
    static class DebuggableSm extends DefaultSecurityManagerImpl {
        public DebuggableSm() {
            this.props.put("aussom.debugger.enable", true);
        }
    }

    public static void main(String[] args) throws Exception {
        Engine eng = new Engine(new DebuggableSm());
        eng.addResourceIncludePath("/com/aussom/stdlib/aus/");
        eng.setDebugger(new HelloDebugger());      // gated; see section 4.1
        eng.parseFile("myscript.aus");

        // Mark line 5 of myscript.aus as a breakpoint.
        eng.setBreakpoint("myscript.aus", 5);

        eng.run();
    }
}

That's everything the interface requires: register, set a breakpoint, run. Every part below is detail on the methods, the inspection surface, and the patterns you'll want for a real debugger.


4. Registering and tearing down

4.1 Security gate: aussom.debugger.enable

The debugging surface is gated by the security property aussom.debugger.enable. The default value is false. Two engine entry points enforce it:

  • setDebugger(d) — throws an aussomException when called with a non-null debugger if the property is false. The engine state is not mutated by the denied call — isDebugMode() stays false and getDebugger() stays null.
  • evalInFrame(source, frame) — throws an aussomException if the property is false. The check runs on every entry, not just at attach time, so that revoking the property at runtime (via setProp on a permissive manager) immediately blocks further evaluation even if a debugger is still attached. This matches the pattern used by Engine.evalLine for aussom.script.mode .enable.

Why a gate: a debugger can read every local, every member, and every paused call frame. evalInFrame is the bigger exposure — it parses and runs arbitrary Aussom source against a live Environment, including the implicit this of a paused method, so a permissive debug surface is effectively an arbitrary-code-execution channel into the running engine. Embedders that do not want to expose this (production servers, sandboxed runtimes, multi-tenant hosts) get the safe default for free.

Enable it by subclassing your security manager and flipping the property in the constructor:

public class MySm extends DefaultSecurityManagerImpl {
    public MySm() {
        this.props.put("aussom.debugger.enable", true);
    }
}

TestSecurityManagerImpl (shipped with aussom-base, used by the JUnit suites) already enables it.

setDebugger(null) — the detach path — is not gated. Clearing a previously-attached debugger is always allowed, regardless of the current property value, so a caller can always tear down.

4.2 Attach and detach

DebuggerInt mine = new MyDebugger();
eng.setDebugger(mine);              // turns debug mode ON
assert eng.isDebugMode();
assert eng.getDebugger() == mine;

// ... run scripts ...

eng.setDebugger(null);              // discards the debugger

The contract:

  • Set the debugger before any interpreter thread starts. Once a thread is running the interpreter, setDebugger changes the debugger reference (volatile, visible immediately) but the engine's debugMode flag is a plain boolean and is not guaranteed to flip back across threads without re-registration. Practically: register up front, before eng.run().

  • setDebugger(null) discards the debugger. The next eval in the slow path sees getDebugger() == null and skips the block. It does not reliably turn debugMode off across threads in the middle of a run.

  • A debugger can be swapped during a session. The debugger field is volatile, so calling setDebugger(newImpl) while interpreter threads are running takes effect on each thread's next eval. Useful for hot-swapping a debugger in tests, or for layering a richer debugger on top of a placeholder during attach.


5. Setting breakpoints

The simplest path uses the engine's three convenience methods:

boolean ok      = eng.setBreakpoint("script.aus", 42);   // -> true if line has code
boolean cleared = eng.clearBreakpoint("script.aus", 42); // -> true if a flag was unset
boolean any     = eng.clearAllBreakpoints();             // wipe every flag in the AST
  • setBreakpoint marks the first AST node at the given line (the outermost statement-level node) and returns true if a node was found, false if the line has no executable code (blank, comment, unrecognized file). The boolean maps directly to DAP's "verified" field.
  • clearBreakpoint unsets the breakpoint flag on every matching node at the line, so it undoes whatever setBreakpoint set as well as any flags the caller flipped manually.
  • clearAllBreakpoints walks the entire AST once and unsets every breakpoint flag. Useful for a "remove all" path.

For more control (marking multiple nodes on the same line, picking a specific sub-expression, etc.) drop down to the underlying field directly:

node.breakpoint = true;        // set
node.breakpoint = false;       // clear

And find the nodes via:

List<astNode> hits = eng.findNodesByLine("script.aus", 42);

This returns every node whose getFileName() matches and getLineNum() equals the supplied line. Empty list means the line has no executable code.

A typical line has several matching nodes — for example, the line x = f() + g(); has at least the assignment, the addition, both function calls, and the variable reference. The convenience setBreakpoint marks the first statement-level node and leaves the rest unmarked; this is what most clients want.

Breakpoint flags are volatile, so you can set or clear them from any thread (including a control thread receiving DAP requests) while interpreter threads are running. The change is visible on the next eval through that node.

5.1 Script-mode: arming breakpoints between parse and eval

Engine.evalLine(source, lineNumber) parses a script-mode fragment and immediately evaluates it. For a debugger this is too coarse: the parsed nodes only exist during the call, so there is no place to call setBreakpoint against them before they run. Breakpoints set against an unparsed line silently return false and are dropped.

evalLine is split into two public methods for this case:

public astStatementList parseScriptLine(String source, int lineNumber) throws Exception;
public AussomType        evalParsedScript(astStatementList body) throws Exception;
  • parseScriptLine parses and appends statements to the synthetic script main's body, then returns the body. It does not evaluate. Rolls back the appended statements and throws on parse error.
  • evalParsedScript evaluates only the statements appended since the last call (tracked by an internal cursor). The body argument is the list returned by parseScriptLine.

The DAP-style flow becomes:

engine.setScriptMode(true);

astStatementList body = engine.parseScriptLine(prelude, 0);
engine.evalParsedScript(body);

body = engine.parseScriptLine(userSource, 1);
applyPendingBreakpoints();         // user-source nodes now exist
engine.evalParsedScript(body);

applyPendingBreakpoints calls setBreakpoint(file, line) on nodes that were just parsed; the breakpoint flag is armed before the eval phase runs. evalLine keeps its existing single-call semantics and is now just a wrapper around the two methods, so embedders that do not need the seam can leave existing call sites alone.

Both methods enforce script mode and the aussom.script.mode.enable security property on every entry, matching the original evalLine contract.


6. Inside onPause: inspecting the paused state

When the engine pauses on a breakpoint or step, onPause is called on the interpreter thread that hit the pause. The implementation is expected to block that thread until you're ready to let it continue.

Everything you need to inspect is on the Environment and the astNode arguments:

public void onPause(astNode node, Environment env, PauseReason reason)
        throws aussomException {

    // Where are we?
    String file     = node.getFileName();
    int    line     = node.getLineNum();
    int    col      = node.getColNum();
    int    lineEnd  = node.getLineNumEnd();
    int    colEnd   = node.getColNumEnd();
    // (line, col) .. (lineEnd, colEnd) is the half-open source
    // range of this AST node; see section 6.1 below.
    long   tid  = Thread.currentThread().getId();

    // The call stack -- a linked list of frames.
    com.aussom.CallStack frame = env.getCallStack();
    while (frame != null) {
        System.out.println("  " + frame.getFileName()
            + ":" + frame.getLineNumber()
            + " in " + frame.getClassName() + "." + frame.getFunctionName());
        frame = frame.getParent();
    }

    // The locals -- a name -> AussomType map.
    for (java.util.Map.Entry<String, com.aussom.types.AussomType> e :
            env.getLocals().getMap().entrySet()) {
        System.out.println("  local " + e.getKey() + " = "
            + ((com.aussom.types.AussomTypeInt) e.getValue()).str());
    }

    // The 'this' object (may be null for top-level frames).
    com.aussom.types.AussomObject self = env.getClassInstance();
    if (self != null) {
        for (java.util.Map.Entry<String, com.aussom.types.AussomType> e :
                self.getMembers().getMap().entrySet()) {
            System.out.println("  this." + e.getKey() + " = "
                + ((com.aussom.types.AussomTypeInt) e.getValue()).str());
        }
    }

    // Block until the control thread releases us.
    waitForUserCommand(tid);
}

The blocking pattern usually uses a per-thread Semaphore, CountDownLatch, or Condition. The control thread (the one processing user input or DAP messages) signals the lock when the user clicks "continue" or "step", and onPause returns.

A complete blocking pattern:

private final java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.Semaphore> locks =
    new java.util.concurrent.ConcurrentHashMap<>();

@Override
public void onPause(astNode node, Environment env, PauseReason reason)
        throws aussomException {
    long tid = Thread.currentThread().getId();
    notifyUiPaused(tid, node, env, reason);

    java.util.concurrent.Semaphore s = locks.computeIfAbsent(tid,
        k -> new java.util.concurrent.Semaphore(0));
    try { s.acquire(); }
    catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}

// Called from the control thread when the user clicks "continue".
public void resumeThread(long tid) {
    java.util.concurrent.Semaphore s = locks.get(tid);
    if (s != null) s.release();
}

6.1 Source ranges on AST nodes

Every astNode carries its full source range, not just a start point:

int startLine = node.getLineNum();        // 1-based
int startCol  = node.getColNum();         // 1-based
int endLine   = node.getLineNumEnd();
int endCol    = node.getColNumEnd();

The range is half-open: (endLine, endCol) is the position of the first character after the node, which is the standard convention used by DAP and LSP. Map directly to Source.line/endLine and Source.column/endColumn on a DAP Source reference, or to LSP Range.start/Range.end.

Notes:

  • A single-token leaf (an integer literal, an identifier) has end on the same line, with endCol > startCol covering the token's text.
  • A multi-line construct (an if block, a class body) has endLine > startLine reaching past the closing brace.
  • The end may sit slightly past the last source character of the construct (in trailing whitespace or comments) because it is derived from the parser's lookahead. It never falls short of the actual end. For tooling that needs the exact last character (e.g. trimming a highlight), trim trailing whitespace from the source text in the range.
  • Nodes constructed outside the parser (e.g. synthetic nodes, programmatic AST building) may have end equal to start. Both fields default to 0 for nodes that were never assigned a position.

7. Stepping (over, into, out)

The engine doesn't know about step semantics. It calls one method on every eval (when debug mode is on and the current node is not breakpointed):

boolean shouldPauseForStep(astNode node, Environment env);

Return true and the engine immediately calls onPause with reason STEP. Return false and execution continues.

You implement the step semantics — step over, step into, step out — by deciding when to return true based on per-thread state you keep yourself.

The typical pattern:

enum StepMode { NONE, INTO, OVER, OUT }

static final class ThreadState {
    StepMode mode = StepMode.NONE;
    int      depthAtStep = -1;        // call-stack depth recorded at step request
}

private final java.util.concurrent.ConcurrentHashMap<Long, ThreadState> byTid =
    new java.util.concurrent.ConcurrentHashMap<>();

@Override
public boolean shouldPauseForStep(astNode node, Environment env) {
    ThreadState s = byTid.get(Thread.currentThread().getId());
    if (s == null || s.mode == StepMode.NONE) return false;

    int depth = currentDepth(env);
    switch (s.mode) {
        case INTO:
            // Pause on the next eval, period.
            s.mode = StepMode.NONE;
            return true;
        case OVER:
            // Pause when call-stack depth is <= recorded depth.
            if (depth <= s.depthAtStep) {
                s.mode = StepMode.NONE;
                return true;
            }
            return false;
        case OUT:
            // Pause when call-stack depth is strictly < recorded depth.
            if (depth < s.depthAtStep) {
                s.mode = StepMode.NONE;
                return true;
            }
            return false;
        default:
            return false;
    }
}

// Called from the control thread after the user clicks "step over".
public void requestStepOver(long tid) {
    ThreadState s = byTid.computeIfAbsent(tid, k -> new ThreadState());
    s.mode = StepMode.OVER;
    s.depthAtStep = depthOfPausedThread(tid);   // captured at pause time
    resumeThread(tid);                           // release the semaphore
}

private static int currentDepth(Environment env) {
    int depth = 0;
    com.aussom.CallStack f = env.getCallStack();
    while (f != null) { depth++; f = f.getParent(); }
    return depth;
}

The same shape covers step-into (pause on next eval), step-over (pause at same depth or shallower), and step-out (pause when shallower than the recorded depth).

Hot path note: shouldPauseForStep is called on every eval when debug mode is on. Keep it fast — one map lookup + a field-or-two check is fine. The recommended pattern (section 9) caches the ThreadState in a ThreadLocal so the hot read is one ThreadLocal.get() instead of a ConcurrentHashMap probe.


8. Pause from outside (DAP "pause" request)

A user clicks "pause" in their IDE. You need to stop a running interpreter thread on its next eval, even though no breakpoint fired.

The interface already supports this — there is no separate API call. You make shouldPauseForStep return true for the target thread on its next eval:

private final java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.atomic.AtomicBoolean> pausePending =
    new java.util.concurrent.ConcurrentHashMap<>();

@Override
public boolean shouldPauseForStep(astNode node, Environment env) {
    java.util.concurrent.atomic.AtomicBoolean ab =
        pausePending.get(Thread.currentThread().getId());
    if (ab != null && ab.compareAndSet(true, false)) return true;
    // ... step-mode checks here ...
    return false;
}

public void requestPause(long tid) {
    pausePending
        .computeIfAbsent(tid, k -> new java.util.concurrent.atomic.AtomicBoolean(false))
        .set(true);
}

The next eval on thread tid sees the flag, the engine fires onPause(node, env, STEP), and your onPause blocks as usual.


9. Multi-threaded considerations

Aussom programs run on many threads (UI events, network callbacks, AussomThread, etc.). The debugger must handle this correctly.

The model:

  1. One DebuggerInt instance per engine. All threads call the same instance. Your implementation must be thread-safe.

  2. Per-thread state lives inside your implementation. aussom-base does not track threads. You typically keep a ConcurrentHashMap<Long, ThreadState> keyed by Thread.currentThread().getId(), plus a ThreadLocal<ThreadState> for fast per-thread reads on the hot path.

  3. onPause blocks the calling thread. Each interpreter thread that hits a breakpoint or step blocks independently on its own semaphore. The control thread releases each thread separately.

  4. Breakpoint flags are visible across threads. The astNode.breakpoint field is volatile; setting it from your control thread takes effect on every interpreter thread on its next eval through that node.

The recommended structure for the hot path is to cache the per-thread state in a ThreadLocal so shouldPauseForStep does not do a shared-map lookup on every eval:

private final java.util.concurrent.ConcurrentHashMap<Long, ThreadState> byTid =
    new java.util.concurrent.ConcurrentHashMap<>();

private final ThreadLocal<ThreadState> mine =
    new ThreadLocal<ThreadState>() {
        @Override protected ThreadState initialValue() {
            long tid = Thread.currentThread().getId();
            ThreadState s = new ThreadState();
            byTid.put(tid, s);
            return s;
        }
    };

@Override
public boolean shouldPauseForStep(astNode node, Environment env) {
    ThreadState s = mine.get();         // fast: thread-local lookup
    return s.pausePending.get()         // AtomicBoolean read
        || isStepLanding(s, node, env); // your step-mode logic
}

// Control thread mutates state through byTid:
public void requestPause(long tid) {
    ThreadState s = byTid.get(tid);
    if (s != null) s.pausePending.set(true);
}

The shared ConcurrentHashMap is only touched by the control thread (which runs at user speed). The interpreter hot path never probes it.


10. Evaluating expressions in a paused frame

While a thread is paused inside onPause, you have its Environment. Use Engine.evalInFrame to parse and run an Aussom snippet against that frame's locals and this:

@Override
public void onPause(astNode node, Environment env, PauseReason reason)
        throws aussomException {
    // ... notify UI, wait for command ...

    // User types: "print x + 1" in the debugger prompt.
    try {
        com.aussom.types.AussomType ret =
            engine.evalInFrame("x + 1;", env);
        if (ret.isEx()) {
            com.aussom.types.AussomException e =
                (com.aussom.types.AussomException) ret;
            ui.write("error: " + e.getText());
        } else {
            ui.write(((com.aussom.types.AussomTypeInt) ret).str());
        }
    } catch (Exception parseErr) {
        ui.write("parse error: " + parseErr.getMessage());
    }
}

What evalInFrame does:

  • Parses the snippet using the script-mode building block.
  • Walks the parsed statements against the supplied frame's environment — its locals, its this, its call stack.
  • Returns the value of the last evaluated statement.

Failure modes:

  • Parse error -> throws aussomException.
  • Runtime error inside the snippet -> returns an AussomException value (check ret.isEx()).
  • A return / break inside the snippet -> unwraps and returns the underlying value.
  • Security denial -> throws aussomException referencing the gated property aussom.debugger.enable. The check fires on every call (not just at debugger attach), so a permissive manager that flips the gate off mid-session immediately stops further evalInFrame requests. See section 4.1.

The snippet runs against the paused environment but does not modify it permanently in surprising ways — assignments do bind locals on the paused frame's Members, which is the same behavior you'd get if the user had typed that line into the script at that point. If you don't want side effects, wrap the input in a function call or evaluate only pure expressions.


11. Handling exceptions

There are two ways an exception flows out of an eval — and each has its own hook on the interface.

11.1 Value form: onException(AussomException, Environment)

Most Aussom errors return an AussomException value from the node's evalImpl. Examples: throw "boom" in user code, division by zero, missing-method dispatch failures. The value flows up through eval frames until something catches it (an Aussom try/catch) or it falls out the top of main.

The engine calls onException(AussomException ex, Environment env) the first time the value flows through an eval. Dedup is via a debuggerSeen flag the engine sets on the value, so each logical exception fires the hook exactly once even though it passes through many frames.

@Override
public void onException(AussomException ex, Environment env) {
    System.out.println("[aussom] " + ex.getText()
        + " at " + env.getCallStack().getFileName()
        + ":" + ex.getLineNumber());
    // Optionally pause here for "exception breakpoints" UX:
    if (pauseOnExceptions) {
        // Same blocking pattern as onPause.
        waitForUserCommand(Thread.currentThread().getId());
    }
}

11.2 Throw form: onException(Exception, Environment)

A small number of error paths throw a Java Exception instead of returning a value. The most common is aussomException (the engine's internal throwable for parse-time and setup errors). You may also see RuntimeException subtypes that escape from extern method code.

The engine wraps the dispatch in a try/catch, calls onException(Exception ex, Environment env) on the first frame that catches it, then re-throws so the unwinding continues as normal. Dedup is via a per-thread ThreadLocal<Throwable> on the engine — same "fires exactly once" guarantee.

@Override
public void onException(Exception ex, Environment env) {
    if (ex instanceof aussomException) {
        // Internal engine throw — usually a missing extern, a
        // not-implemented path, etc.
        System.out.println("[engine] " + ex.getMessage());
    } else {
        // Unchecked Java exception escaping from extern code.
        System.out.println("[java] " + ex.getClass().getSimpleName()
            + ": " + ex.getMessage());
    }
}

You almost never want to swallow a throw-form exception (returning normally from the hook lets the engine re-throw). If you want "pause on exception" behavior, block in this method the same way you do in onPause.

11.3 "Pause on uncaught exceptions only"

DAP debuggers offer this as an option. The engine does not distinguish caught from uncaught — the hook fires on the first sighting, before anything has had a chance to catch. To implement "uncaught only," your debugger needs to record the exception in the hook and then observe whether it gets consumed by an Aussom try/catch later. If it doesn't, surface it to the user. The mechanism is debugger-side; the interface gives you the visibility you need.


12. The DebuggerInt interface, summarized

public interface DebuggerInt {
    void onPause(astNode node, Environment env, PauseReason reason)
        throws aussomException;

    boolean shouldPauseForStep(astNode node, Environment env);

    void onException(AussomException ex, Environment env)
        throws aussomException;

    void onException(Exception ex, Environment env)
        throws aussomException;
}

public enum PauseReason { BREAKPOINT, STEP }

That's the entire contract. Everything else — step modes, breakpoint conditions, hit counts, logpoints, exception filters, stack-trace rendering, variable-tree expansion, "pause from outside," "uncaught only" — lives in your implementation, not in aussom-base.


13. Common patterns and gotchas

  • Register the debugger before eng.run(). Setting it after a thread is already inside the interpreter doesn't reliably engage debugMode on that thread.

  • onPause runs on the interpreter thread. Do not call back into the engine from onPause with operations that themselves block on the same thread.

  • shouldPauseForStep is on the hot path. When debug mode is on, it's called once per eval. Keep it fast — one ThreadLocal.get() + a couple of field reads is the expected cost.

  • Breakpoints are per-AST-node, not per-line. A single line can have many nodes. Mark only the ones you want to pause on (typically the first statement-level node from findNodesByLine).

  • findNodesByLine returns nodes from every parsed class plus the synthetic script class. If your debugger embedder uses Engine.setScriptMode(true) and Engine.evalLine, script-mode statements are findable too.

  • Multi-threaded callers must block per-thread. Don't share one semaphore across threads — each interpreter thread that hits a pause needs its own blocker so they release independently.

  • evalInFrame runs against the paused environment. Assignments in the snippet bind locals on that frame. If that's not what you want, scope your snippets carefully or pre-validate them.

  • Exceptions fire once. The engine dedups both forms. You do not need to handle the same exception flowing up through multiple frames.

  • Error is not caught. The throw-form hook catches Exception but lets Error (OOM, StackOverflowError) propagate naturally. Don't try to use the hook to recover from JVM-level failures.

  • Debug mode has a measurable cost. When it's on, every eval pays for the gate check, the breakpoint read, the shouldPauseForStep call, and the post-eval check. The engine is structured to keep production runs (debug off) essentially free, but a running debug session is slower. Don't leave debug mode on in performance-sensitive production code paths.