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.1 and 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​

  1. Load the extension. Open tools/vscode-jaiscript in VS Code and press F5 (an Extension Development Host opens), or install it for good:
    shell
    cd tools/vscode-jaiscript
    npm install
    npx @vscode/vsce package               # produces jaiscript-*.vsix
    code --install-extension jaiscript-*.vsix
  2. Run the script in a host that listens. jaiscript --debug file.jai listens 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 calls jai::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.
  3. 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 }
      ]
    }
  4. Click in the gutter of a .jai file. When it stops you can step over, into and out, continue and pause.
paneshows
Call Stackevery frame, innermost first, named and located as an error's stack names it; the top level is last, as <script>
Localswhat 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.
Globalsthe program's own variables. Callables and what the host registered with add_global stay out.
Watch, hover, Debug Consolea 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​

C++
#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 it

listen 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_file and import stamp every node with the file's std::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 verified once the engine has parsed a file with its path. Set earlier, it answers unverified and turns verified, with a breakpoint event, 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 .jaibite unit keeps each node's source path, so it debugs against its .jai source.

The runner and the example hosts​

flagjaiscript runnerjai_crawler, jai_demoreel
--debuglisten on 52477, or the next free port in steps of 10listen on 52472 for the whole run
--debug-port N (runner: --debug-port=N)listen, trying N firstlisten on N
--debug-waithold 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 wireuse instead
editing a variabledbg.set_variable inside a post_command closure (below)
conditional breakpoints, logpointsan if in the script with the breakpoint inside it
exception breakpointsa breakpoint on the throw, on the failing call, or in a catch
evaluating an expressiona local that holds it; step past its declaration
breakpoints inside a running parallel bodya 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):

jaiscript
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)); }
}
output
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:7

A 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.

C++
#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();
callwhere it may run
the first engine::debugger(), set_on_stoppedbefore the script thread starts, or on it
set_breakpoints, clear_breakpoints, set_enabled, resume, step_*, post_command, detachany thread; set breakpoints from one thread at a time
get_variable, set_variable, evaluate, list_frames, list_locals, list_globalsthe 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, and sleep(0) syncs without waiting;
  • once a session is armed, the next statement.
changetakes effect
breakpoint set or pause requested, session armedat the next statement the script runs
pause while the script sleepsinside the sleep, at the statement that sleeps; the rest of the sleep runs after the resume
attach to a running scriptat 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 sleepwithin 1024 back edges and calls of script work, which can be several frames
any change while the script is inside a host callwhen control returns to the script
a stepexactly: 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-Length line drops the client before any of it is read as a request.
  • A client configures before it attaches. Before attach it 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(), a step_*() or a post_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; only clear_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.