Debugging
Every engine carries a step debugger: breakpoints, pause, step, a call stack, and reads and writes of a stopped
script's variables. A host drives it from C++ through engine::debugger(), or opens a port with
jai::debug::listen so VS Code attaches over the Debug Adapter Protocol. The VS Code extension in
tools/vscode-jaiscript supplies the attach type and syntax highlighting.
Two things to know before you turn it on.
- An armed session switches the execution budget off. While a debugger is attached, no script is stopped by its wall-clock deadline (a script paused at a breakpoint must not be killed); a fresh deadline starts when the session ends.
- Any process on the machine can attach. The listener binds
127.0.0.1and asks for no password. An attached client can pause the script indefinitely and read every variable.
Nothing listens unless asked: the library opens a port only when the host calls jai::debug::listen, and
the runner and example hosts only under --debug. To leave the socket code out of a build entirely, configure
with -DJAISCRIPT_ENABLE_DEBUGGER=OFF (the option defaults to ON;
Building).
The model:
- The script thread parks; another thread drives. A script blocks at a stop inside the statement hook. Control calls (breakpoints, resume, step) come from any thread; reads and writes of script state run on the parked thread.
- Breakpoints bind by file path. Only code the engine parsed with a path (
execute_file,import) can be matched to an editor file;execute(source)code is<script>. - Changes land at sync points. An attach, a breakpoint edit or a pause reaches a running script at its next sync point; once a session is armed, that is the next statement.
- It is free until a session arms: one predictable branch per statement, on both backends (measured).
- A vanished client never hangs the host. Disconnects, socket errors and engine teardown all resume a parked script.
Debug a script from VS Code
- Load the extension. Open
tools/vscode-jaiscriptin VS Code and press F5 (an Extension Development Host opens), or install it for good:shellcd tools/vscode-jaiscript npm install npx @vscode/vsce package # produces jaiscript-*.vsix code --install-extension jaiscript-*.vsix - Run the script in a host that listens.
jaiscript --debug file.jailistens on port 52477, or the next free one in steps of 10, and logs it (debugger: DAP listening on 127.0.0.1:52477). Your own host callsjai::debug::listen(*eng)(port 52472). The script must still be running when you attach: a game loop or anything that waits for input, not a script that finishes at once. - Attach. Add this configuration with the port your host logged, and press F5:
.vscode/launch.json
{ "version": "0.2.0", "configurations": [ { "type": "jaiscript", "request": "attach", "name": "Attach to JaiScript", "host": "127.0.0.1", "port": 52472 } ] } - Click in the gutter of a
.jaifile. When it stops you can step over, into and out, continue and pause.
| pane | shows |
|---|---|
| Call Stack | every frame, innermost first, named and located as an error's stack names it; the top level is last, as <script> |
| Locals | what is in scope at the frame's statement: the parameters and each local an enclosing block has already declared. An inner name hides an outer one. |
| Globals | the program's own variables. Callables and what the host registered with add_global stay out. |
| Watch, hover, Debug Console | a name and its .field, [index] and ["key"] steps (player.inventory[0]). An operator or a call answers that it cannot be evaluated. |
Each row carries its kind (int, string, array, object, ...); a string
shows quoted, and a value's text stops after 1000 bytes.
Listening from your host
#include <jaiscript/debug/connector.hpp>
auto conn = jai::debug::listen(*eng); // jai::debug::default_port (52472)
// auto conn = jai::debug::listen(*eng, 40000); // another port
// eng->set_debug_connector(nullptr); // stop listening and release itlisten builds a jai::debug::debug_connector and hands it to the engine with
set_debug_connector. The connector is a DAP server on a raw OS socket with its own thread and no external
adapter; it listens for the engine's whole life, so an editor can attach, detach and attach again. The engine stops it
before its own teardown.
A taken port does not throw: the connector comes back not serving, with conn->listening() false,
conn->port() 0 and conn->error() saying why
(debugger cannot listen on 127.0.0.1:52472: bind failed (...)). A host with several engines tries the next
port, as the runner does.
How a breakpoint binds
execute_fileandimportstamp every node with the file'sstd::filesystem::canonical()path, and the connector canonicalizes the path the editor sends, so drive-letter case and slash direction do not matter.- A breakpoint is
verifiedonce the engine has parsed a file with its path. Set earlier, it answers unverified and turns verified, with abreakpointevent, when the file first loads. - On a line no statement starts on (blank, a comment, a closing brace) it moves to the next line that starts one. Past the last statement it stays unverified.
- A
.jaibiteunit keeps each node's source path, so it debugs against its.jaisource.
The runner and the example hosts
| flag | jaiscript runner | jai_crawler, jai_demoreel |
|---|---|---|
--debug | listen on 52477, or the next free port in steps of 10 | listen on 52472 for the whole run |
--debug-port N (runner: --debug-port=N) | listen, trying N first | listen on N |
--debug-wait | hold boot until you attach and press Enter, to catch start-up code |
The crawler loads its scripts with import, so scripts/*.jai breakpoints bind. The demoreel
normally runs one precompiled unit; under --debug it loads each scene with execute_file so
scenes/*.jai breakpoints bind.
What the connector does not do
| not over the wire | use instead |
|---|---|
| editing a variable | dbg.set_variable inside a post_command closure (below) |
| conditional breakpoints, logpoints | an if in the script with the breakpoint inside it |
| exception breakpoints | a breakpoint on the throw, on the failing call, or in a catch |
| evaluating an expression | a local that holds it; step past its declaration |
| breakpoints inside a running parallel body | a parallel region is atomic: a breakpoint on the parallel_transform line stops before the call and a step lands after the join. Call the body serially on one element to debug it. |
Without a debugger
The strict checker reports typed mistakes and undeclared names before anything
runs. At run time a caught error carries 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:7A host gets the same from a failed checked_result through eng->format_error(r) and
eng->format_stack_trace() (Error surfaces).
The controller (C++ API)
eng->debugger() returns the engine's jai::debug::controller
(jaiscript/debug/controller.hpp), created on first use. It is the object the connector drives.
#include <jaiscript/core/engine.hpp>
#include <jaiscript/debug/controller.hpp>
#include <thread>
auto eng = jai::engine::make();
auto& dbg = eng->debugger(); // first call builds it: before the script thread starts, or on it
dbg.set_on_stopped([&](const jai::debug::stop_info& si) {
// Runs on the script thread as it parks. si.reason is "breakpoint" | "step" | "pause";
// si.file, si.line, si.depth. Signal your driver thread from here.
});
dbg.set_breakpoints("<script>", {3}); // keyed by the file name the code carries
dbg.set_enabled(true); // arm the session (suspends the execution budget)
std::thread script([&] {
eng->execute("var x = 1;\n"
"var y = 2;\n"
"var z = x + y;\n"); // stops before line 3 runs
});
// ...once on_stopped has fired (dbg.is_paused() is true), from the driver thread:
dbg.post_command([&] { // the closure runs on the parked script thread
jai::script_value x = dbg.get_variable("x"); // 1
// dbg.set_variable("x", eng->make_value(41));
auto row = dbg.evaluate("x"); // {name, value text, kind}
auto frames = dbg.list_frames(); // {name, file, line}, innermost first
auto locals = dbg.list_locals(0); // the locals of frames[0]
auto globals = dbg.list_globals();
});
dbg.step_into(); // 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(); // run to the next breakpoint or the end
script.join();| call | where it may run |
|---|---|
the first engine::debugger(), set_on_stopped | before the script thread starts, or on it |
set_breakpoints, clear_breakpoints, set_enabled, resume, step_*, post_command, detach | any thread; set breakpoints from one thread at a time |
get_variable, set_variable, evaluate, list_frames, list_locals, list_globals | the parked script thread, inside a post_command closure |
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), then the program scope. A set
obeys the variable's declared type. A name nothing in scope has throws; neither call declares a global.
Sync points
The script thread tests one cached pointer per statement and takes no lock. Changes made from another thread reach that cache at:
execute()entry;- the budget clock's sample, every 1024 loop back edges and calls;
- every
sleep: a change wakes the sleeping script, which syncs and sleeps on, andsleep(0)syncs without waiting; - once a session is armed, the next statement.
| change | takes effect |
|---|---|
| breakpoint set or pause requested, session armed | at the next statement the script runs |
| pause while the script sleeps | inside the sleep, at the statement that sleeps; the rest of the sleep runs after the resume |
| attach to a running script | at the next execute(), the next or current sleep, or within 1024 back edges and calls |
attach to a frame loop the host drives through script function calls, with no execute() and no sleep | within 1024 back edges and calls of script work, which can be several frames |
| any change while the script is inside a host call | when control returns to the script |
| a step | exactly: into stops at the next statement at any call depth, over at this depth or shallower, out shallower |
Call sleep(0) once per frame in a host-driven loop so an attach, a breakpoint or a pause
takes effect within the frame. It costs one flag test while nothing is pending.
Who can attach
Any process on the machine, and nothing off it. On a shared machine do not listen. Within that:
- A web page cannot drive it. A page can reach a loopback port only as HTTP, and a header block
that is anything but one
Content-Lengthline drops the client before any of it is read as a request. - A client configures before it attaches. Before
attachit may initialize and set breakpoints. A request that drives or reads the script answers that it is not attached, and a pause asked for before a session never stops the next one. - The listener never shares a port. A port another process listens on, on any address, is reported busy.
- A misbehaving client is bounded. A header block past 64 KB or a body declared over 64 MB disconnects the client. While 4 MB of replies wait unread, the connector stops reading that client's requests.
Concurrency and lifecycle
- A stop never loses a resume. The script thread marks itself paused before it reports the stop, so
a
resume(), astep_*()or apost_command()that races the stop still wakes it. - Breakpoint edits are published, not queued. An edit is acknowledged at once and applied by the script
thread at its next sync point, in order. Breakpoints survive
set_enabled(false)and a re-arm; onlyclear_breakpoints()removes them. The filesystem privilege is checked when an edit is applied, and a later revocation does not remove an installed breakpoint. detach()always wakes a parked thread and disarms. A client disconnect, a socket error and the connector's destructor all detach.- Stop a transport on the script's thread or while no script runs (
set_debug_connector,stop(), its destructor). A connector the host keeps outside the engine must be destroyed before the engine. - Posted commands run without the hook. Script code a command runs hits no breakpoint and takes no step.
- Variable handles last one stop. A request with a handle from before the last resume gets an error.
Syntax highlighting
The same extension highlights .jai, .script and .jaibite files: keywords (from
the lexer's own table), the int/auto/var type ladder, template strings with
${expr}, operators, numeric literals and the stdlib builtins. It needs no engine build and no running
host. For diagnostics, completion and navigation see the language server.