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.
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:
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.
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 callsetDebugger(yourImpl)before any interpreter thread starts running. You parse Aussom source as usual. You usefindNodesByLineto find the AST nodes for your breakpoints, set theirbreakpointfield totrue, then calleng.run()(or any other entry that starts evaluating). When a node hits the breakpoint, the engine callsyourImpl.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.
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.
aussom.debugger.enableThe 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.
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.
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.
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.
onPause: inspecting the paused stateWhen 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();
}
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:
endCol > startCol covering the
token's text.if block, a class body) has
endLine > startLine reaching past the closing brace.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.
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.
Aussom programs run on many threads (UI events, network
callbacks, AussomThread, etc.). The debugger must handle
this correctly.
The model:
One DebuggerInt instance per engine. All threads call
the same instance. Your implementation must be thread-safe.
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.
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.
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.
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:
this, its call stack.Failure modes:
aussomException.AussomException value (check ret.isEx()).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.
There are two ways an exception flows out of an eval — and each has its own hook on the interface.
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());
}
}
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.
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.
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.
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.