Debugging
JaiScript ships a step-debugger core (set breakpoints, pause a running script, inspect and edit variables, step, and resume) plus a VS Code extension for syntax highlighting and DAP attach. This page shows each piece and how to drive it, from a one-line editor attach down to the raw C++ controller.
Three pieces, three audiences. (1) Editor syntax highlighting: standalone, no engine build needed. (2) The debugger core, an engine-owned controller you can drive directly from C++: breakpoints, stepping, and live variable reads and writes on a parked script. (3) The DAP debug_connector, a dependency-free TCP server that lets VS Code attach: click breakpoints in the gutter, stop, step (over/into/out), and read in-scope variables. Both backends carry the statement hook, and it costs nothing until a session attaches (How it works).
Debug a script, step by step
- Load the extension from
tools/vscode-jaiscript(below: F5 in that folder, or install the packaged.vsix). - Run the script through a host that listens:
jaiscript --debug file.jaistands up the standalone runner's listener on port 52477 or the next free one in steps of 10 and logs the port it bound (debugger: DAP listening on 127.0.0.1:52477); a host of your own callsjai::debug::listen(*eng), port 52472. Nothing listens unless asked. The script must still be running when you attach, so this suits a game loop or anything that waits for input; a short script finishes before the editor connects. - In VS Code add the attach configuration with that port and press F5.
- Click in the gutter of the
.jaifile. Run scripts throughexecute_fileorimportso their nodes carry the file's path; a breakpoint binds by path. - When it stops: step over, into and out, continue, walk the Call Stack, and read each frame's locals and the globals in the Variables pane.
Without a debugger
Most mistakes never reach run time: the strict checker lists every typed mistake and undeclared name in a file before anything runs (Static Checking). For the rest, a caught error knows where it came from: e.message, e.line, e.column and e.stack (each frame file:line, innermost first, the innermost 64 kept):
function load_level(int n) {
array<int> sizes = [8, 16];
return sizes[n];
}
function start() { return load_level(5); }
try {
start();
} catch (e) {
print("error: ", e.message);
print("where: line ", e.line, ", column ", e.column);
for (auto frame : e.stack) { print(" at ", file_name(frame)); }
}error: Array index 5 out of bounds for array of size 2
where: line 3, column 5
at main.jai:3
at main.jai:5
at main.jai:7On the host side, a failed checked_result gives the same through eng->format_error(r) and eng->format_stack_trace(), and an error nothing catches prints its message and stack from the runner.
Syntax highlighting (VS Code)
The extension lives in tools/vscode-jaiscript/. It highlights .script, .jai, and .jaibite files: the keywords, the int/auto/var type ladder, function/class/coroutine, backtick template strings with ${expr} interpolation, all operators (including <=> and ?.), numeric literals, and the stdlib builtins. The keywords follow the lexer's keyword table (int64, for instance, is not a keyword and is not highlighted as one).
Load it the quickest way: open the extension folder in VS Code and press F5 to launch an Extension Development Host, then open any .jai file:
code tools/vscode-jaiscript
# in the new window: press F5 -> "Extension Development Host" opens
# open any .jai file -> it is highlightedTo install it persistently instead, package it to a .vsix and install that:
cd tools/vscode-jaiscript
npm install
npx @vscode/vsce package # produces jaiscript-*.vsix
code --install-extension jaiscript-*.vsixSyntax highlighting is entirely standalone. It needs no engine build and no running app. (The extension also declares the jaiscript attach debug type the next section uses.)
Attach from VS Code
The jai::debug::debug_connector is a self-contained DAP server: it speaks the Debug Adapter Protocol directly on a raw OS socket (WinSock / BSD sockets; no asio, no Node adapter, no external tool), runs on its own thread, and drives the engine's debugger(). VS Code's built-in DAP client connects straight to the port. One line of host code turns it on:
#include <jaiscript/debug/connector.hpp>
// One connector per engine (typical). The engine keeps it alive and stops it — joining
// its thread and detaching the controller — before teardown, so a paused script can
// never wedge the host. The jai::debug::listen() convenience builds the connector, attaches
// it, and returns it (the whole host-side wiring in one line):
auto conn = jai::debug::listen(*eng); // default port jai::debug::default_port (52472)
// auto conn = jai::debug::listen(*eng, 40000); // custom port
// eng->set_debug_connector(nullptr); // detach + release
//
// listen() is exactly this, if you want to hold the pieces yourself:
// eng->set_debug_connector(std::make_shared<jai::debug::debug_connector>(52472));A taken port does not throw: listen hands back a connector that is not serving, conn->listening() answers false, conn->port() answers 0, and conn->error() says why (debugger cannot listen on 127.0.0.1:52472: bind failed (...)). A host that runs several engines tries the next port, as the runner does.
It is compiled only when the JAISCRIPT_ENABLE_DEBUGGER CMake option is on (the default for desktop dev/editor builds; turn it off for shipping / mobile and no socket code is built at all). Then, in VS Code, add an attach configuration and press F5 while the host is running:
{
"version": "0.2.0",
"configurations": [
{ "type": "jaiscript", "request": "attach", "name": "Attach to JaiScript",
"host": "127.0.0.1", "port": 52472 }
]
}The port is whatever your host logs at startup: jai::debug::default_port (52472) for the example hosts, 52477 for the standalone runner run with --debug (it probes upward by 10 if that port is busy).
Set breakpoints in the gutter of your .jai/.script file, attach, and the running engine stops there. You get the stop location highlighted, step over/into/out, continue, pause, the Call Stack pane listing every frame (innermost first, each named and located as an error's stack names it, the top level last as <script>), and the Variables pane showing the selected frame's Locals and the program's Globals (read-only over the wire; see the last section for the live-edit route). Locals lists what is in scope at the frame's statement, as the source resolves names there: the parameters, read or not, and each local an enclosing block has already declared; a local of a block that has ended is gone, one declared further down is not there yet, and an inner name hides an outer one of the same name. Globals lists the program's own variables; what the host registered with add_global stays out. Each row carries its kind (int, string, array, object, ...); a string shows quoted, and a value's text stops after 1000 bytes. Watch expressions, hovers and the Debug Console evaluate a name and its .field, [index] and ["key"] steps (player.inventory[0]) as the stopped statement resolves them; anything else (an operator, a call) answers that it cannot be evaluated.
Breakpoints bind by file path. A node only carries a real path if the engine parsed it with one, so run gameplay scripts through execute_file(path, vars) or import (both stamp every node with the file's std::filesystem::canonical() path), not the bare execute(source) (which stamps "<script>"). The connector normalizes the path VS Code sends to the same canonical form before matching, so a breakpoint binds regardless of drive-letter case or forward/backslashes. The two spellings don't have to be byte-identical, and execute_file canonicalizes whatever spelling it is given (mixed slashes, . segments). A breakpoint answers verified: true only when a script the engine has parsed carries its path (<script> always does); one set before its file loads answers unverified and turns verified with a breakpoint event the moment the engine first parses that file. A breakpoint on a line no statement starts on (a blank line, a comment, a closing brace) binds to the next line that starts one, and the answer moves it there; past the file's last statement it stays unverified. A .jaibite unit debugs against its original .jai source unchanged: it round-trips the source path per node. See jaibite.
Try it: the example hosts
The bundled demos (examples/crawler, examples/demoreel) accept --debug: it stands up the connector via jai::debug::listen() on one engine for the whole session (one engine, one port, no backend swapping mid-session). The crawler debugs on whichever backend --backend chose; the reel uses a single interpreter engine for the reason below. The connector is tied to the engine's lifetime: it listens for the whole run, so you can attach (and detach and re-attach) at any time; an attach and the breakpoints you set reach the running scripts at their next debug sync point (How it works). Open the repository root in VS Code, add the attach configuration from the previous section (the extension's JaiScript: Attach snippet writes it for you; set the port to 52472), then:
jai_crawler.exe --debug # boots immediately; listening on 127.0.0.1:52472 the whole run
# in VS Code, whenever you like: F5 (Attach to JaiScript) -> set breakpoints in scripts/*.jai
jai_crawler.exe --debug-wait # same, but HOLD boot until you attach + press Enter
# (use this to catch boot-time code: dungeon gen, class init)
jai_crawler.exe --debug-port 40000 # attach on a different portThe standalone runner debugs the same way: jaiscript --debug file.jai stands up the listener on port 52477 (--debug-port=N listens too, trying port N first), and a file run through it carries its path, so gutter breakpoints in that file bind. Without one of those flags it opens no socket.
The crawler loads its scripts with import, so every scripts/*.jai node already carries a canonical path. The demoreel normally concatenates every scene into one precompiled .jaibite unit (stamped "<script>", nothing to bind); under --debug it instead loads each scene through execute_file on a single interpreter engine, so scenes/*.jai breakpoints bind (TAB backend-swap is disabled in that mode: the debug session is one engine for its whole life).
Under the hood this is the same wire protocol proven by the Debugger Connector test suite (source/tests/debug/debugger_connector_tests.cpp), which drives a real loopback socket through the whole loop: handshake → setBreakpoints → run → stopped → stackTrace → scopes → variables → stepIn/next → continue, with no editor in the loop.
The debugger core (C++ API)
Every engine owns a lazily-created jai::debug::controller, reached with engine->debugger() (include/jaiscript/debug/controller.hpp). It is transport-agnostic: the same object the DAP connector drives over the wire, you can drive directly from C++.
The essential contract: a script runs on one thread and blocks at a breakpoint; a different thread drives it and inspects state. The controller's control-side calls (breakpoints, resume, step) are safe from any thread. Setup is not: the first engine::debugger() call, which builds the controller, and set_on_stopped happen before the script thread starts, or on it; inspection (reading/editing variables) must run on the parked script thread, which you arrange by handing the controller a closure via post_command().
#include <jaiscript/core/engine.hpp>
#include <jaiscript/debug/controller.hpp>
#include <thread>
auto eng = jai::engine::make(); // either backend debugs: the VM, or the interpreter via set_backend
auto& dbg = eng->debugger();
// 1. React to stops. Fires ON the script thread the instant it parks.
dbg.set_on_stopped([&](const jai::debug::stop_info& si) {
// si.reason is "breakpoint" | "step" | "pause"; si.file / si.line / si.depth
// Signal your driver thread here (a condition_variable, a queue, ...).
});
// 2. Set breakpoints and arm the session. A script run via execute(src) is attributed
// to "<script>"; one run via execute_file(path) is attributed to that path — so key
// breakpoints on whichever file name the script carries.
dbg.set_breakpoints("<script>", {3});
dbg.set_enabled(true); // also suspends the wall-clock budget while attached
// 3. Run the script on its own thread — it will block at line 3.
std::thread script([&] {
eng->execute("var x = 1;\n" // line 1
"var y = 2;\n" // line 2
"var z = x + y;\n"); // line 3 <- stops BEFORE this runs
});When it stops, inspect and steer it from your driver thread. get_variable and set_variable resolve a name as the stopped statement does: the innermost frame's locals, then its enclosing scopes (a closure's captures, the top level's blocks) and the program scope. A set obeys the variable's declared type, and a name nothing in scope has throws; neither ever declares a global. Reads run on the parked thread via post_command; stepping and resuming are direct calls:
// ...after your on_stopped fired and you know it is paused (dbg.is_paused() == true):
// Inspect: the closure runs on the parked script thread, against live state.
dbg.post_command([&] {
jai::script_value x = dbg.get_variable("x"); // 1 (line 1 ran; line 3 hasn't)
// dbg.set_variable("x", eng->make_value(41)); // edit it live if you like
auto row = dbg.evaluate("x"); // {name, value text, kind}: a name and its .field / [index] steps
auto frames = dbg.list_frames(); // the call stack: {name, file, line}, innermost first, "<script>" last
auto locals = dbg.list_locals(0); // frame 0's locals; list_locals(i) for frames[i]
auto globals = dbg.list_globals(); // the program scope (callables hidden)
});
// Step / continue (all safe from the driver thread):
dbg.step_into(); // stop at the next statement, any depth
dbg.step_over(); // next statement at this depth or shallower
dbg.step_out(); // next statement shallower than this one
dbg.resume(); // continue until the next breakpoint / completion
script.join();The full, runnable version (breakpoint → read a variable → step → resume, plus a disconnect-safety case) is the Debugger suite in source/tests/debug/debugger_tests.cpp. Run it with jaiscript_tests.exe "Debugger".
How it works
- The hook. The interpreter tests one plain cached pointer per statement (at
dispatch_stmt/dispatch_decl), the VM once per dispatch iteration; with no session enabled that predictable branch is the hook's entire cost: no atomics, no locks, no lookups on the statement path, ever. Transport-side changes (attach, breakpoint edits, pause, detach) are pulled into the script thread's cache only at its debug sync points:execute()entry, the script-timeout clock's sample every 1024 loop back edges and calls, everysleepthat blocks (a change wakes the sleeping script thread, which syncs and sleeps on for the rest of the time; a run suspended insleepis no stop, and its next resume is a host entry like any other), everysleep(0)(it waits for nothing and never suspends a run: pending breakpoint edits apply there and a pending pause parks there, at one flag test while no change is pending), and, once a session is armed, the next statement after a change (the change raises the same flag the step/pause gate already reads, so an armed statement costs what it did). Callsleep(0)once per frame in a host-driven loop to make an attach, breakpoints and a pause take effect at once: a breakpoint set while the loop runs is hit at the next statement that has one after that call. - Breakpoints live in a published snapshot the script thread caches locally; per statement they filter through an 8 KB line bloom, so with a session attached and no breakpoint on a statement's line the cost stays a couple of branches.
- Park & pump. On a stop the script thread blocks in the hook on one mutex + one condition variable (predicate-guarded, so a resume racing the stop is never lost). While parked it drains posted closures. That is how the other thread inspects live
environmentstate without ever touching engine data itself. - Stepping is computed from call-stack depth: into = next statement any depth; over = depth ≤ the stop; out = depth < the stop.
- Budget. While a session is armed both backends skip wall-clock deadline checks (a script paused at a breakpoint is never killed by the execution budget) and re-arm a fresh deadline the moment the session ends.
- detach() force-resumes a parked thread and disarms: the safety valve for a client disconnect or app shutdown, so a paused script can never wedge the host.
Performance. The debugger is designed to cost nothing when you are not using it, and almost nothing when you are: with no debugger constructed, with a connector listening but no client, with a session attached and no breakpoints, and with breakpoints only in cold files, the hot-loop benchmark is flat within run-to-run noise on both backends. The one measurable case is a breakpoint whose line number collides with a hot statement's line in another file, bounded by one string compare per collision. What the sync points guarantee: while a session is armed, a breakpoint set or a pause requested mid-execution takes effect at the next statement the script runs. A pause requested while the script sleeps stops it inside the sleep, at the statement that sleeps; the rest of the sleep runs after the resume. An attach reaches a running script at its first sync point after it: the next execute(), the sleep it is in or the next one, or at most 1024 loop back edges and calls, so a loop paced by sleeps stops at once however few statements it runs between them. A frame loop the host drives through calls into script functions, with no execute() and no sleep, takes an attach within 1024 back edges and calls of script work, which can be several frames. A script blocked inside a host call takes the change when control returns to it. Stepping is exact and immediate, since the step state is re-synced at every stop. The measured cost model is quantified in Performance.
Both backends debug. The interpreter and the bytecode VM carry the same statement hook against one shared controller. Breakpoints, stepping and locals inspection behave identically on either, with the same near-zero cost model (the VM detects statement boundaries from its per-instruction line info and names a stopped frame's locals from its chunk's table of each local's slot and the instructions it is in scope across, kept beside the instructions). One semantics rule: parallel regions are atomic to the debugger. A breakpoint on the parallel_transform call line breaks before the call and a step lands after the join, but breakpoints inside the parallel body never fire while it runs as part of a region; a body is admission-enforced pure, so debug it by calling it serially on one element (identical semantics guaranteed).
What the connector doesn't do (and what to use instead)
- The Variables pane is read-only over the wire. Live editing works through the C++ controller:
dbg.set_variable(...)inside apost_commandclosure (see above). - No conditional breakpoints or logpoints over the wire. Wrap the interesting case in an
ifand set the breakpoint inside it: same effect, and the guard survives in version control. - No exception breakpoints. An error nothing catches ends the run (the runner prints its message and stack); to stop where it starts, set a breakpoint on the
throwor on the call that fails, or catch it and set one in thecatch. - Evaluate reads, it does not compute. A watch or the Debug Console takes a name and its
.field/[index]/["key"]steps; for anything computed, add a local holding it and step past its declaration.
Who can attach
The listener binds 127.0.0.1 only, so nothing off the machine reaches it, and it asks for no password: any process on the machine can attach, pause the script for as long as it likes and read every global and every local of every frame. So nothing stands it up unasked: the standalone runner and the example hosts listen only under --debug or a flag that implies it (--debug-port, --debug-wait), and the library listens only when a host calls jai::debug::listen. On a shared machine leave it off, and build a shipping host without JAISCRIPT_ENABLE_DEBUGGER (no socket code at all). Within that, the connector keeps to the debugger's own clients:
- A web page cannot drive it. A page can send a request to a loopback port, but only as HTTP; a header block that is anything but one
Content-Lengthline drops the client before a byte of it is read as a request. - A client configures before it attaches. Until
attach, a client may initialize, set breakpoints and finish its configuration; a request that drives or reads the script (pause, step, continue, the stack, variables, evaluate) answers that it is not attached, and a pause asked for before a session never stops the next one. - It never shares a port. A port another process listens on, on any address, is taken: the listener does not bind beside it (which would take that process's local connections) but reports the port busy, and the runner moves on to the next one.
The concurrency & lifecycle contract
The rules behind all of this, which a host that drives the controller from its own thread relies on:
- A stop never loses a resume. One controller mutex and one condition variable guard the parked state, the step mode and the command queue. The script thread marks itself paused before it reports the stop and then waits on a predicate, so a
resume(), astep_*()or apost_command()that races the stop still wakes it. - Breakpoint edits are published, not queued. A breakpoint set from the transport thread is acknowledged at once and published as a snapshot; the script thread applies it at its next debug sync, in the order the edits were made, and checks the filesystem privilege then (an installed breakpoint is not removed by a later revocation). Breakpoints survive
set_enabled(false)and a re-arm; onlyclear_breakpoints()removes them. Set breakpoints from one thread at a time. detach()always wakes a parked thread. A client disconnect, a socket error and the connector's destructor all detach, so a vanished editor cannot hang the host. The engine stops the connector it holds (joining its thread) at the start of its own teardown; a connector the host keeps outside the engine must be destroyed before the engine. A transport stops (set_debug_connector,stop(), its destructor) on the script's thread or while no script runs: a parked script and the replies it is running belong to the transport.- One writer on the wire. Every socket write happens on the connector thread; a stop event or a command's reply raised on the script thread is queued for it.
- A misbehaving client is bounded. A header block past 64 KB, or a body declared over 64 MB, disconnects the client, and the listener takes the next one. Requests are framed as they arrive, and while 4 MB of replies wait unread the connector stops reading that client's requests: a client that never reads stalls itself instead of growing the host's memory.
- Debug commands run without the hook. Script code a posted command runs on the parked thread hits no breakpoint and takes no step, so it never parks inside itself.
- Variable handles last one stop. A handle the editor received is valid until the next resume; a request with a stale one gets an error and never reaches the unwound frame.