Docs/The debugger
Guide

The debugger

DominScript ships with a step debugger that lives in Visual Studio Code / VSCodium. This page is everything about it in one place: how to install it, how to start a session, what you see when the script stops, and what the Watch panel will and will not evaluate for you.

VS Code / VSCodiumextension dominscript-0_0_631.vsixhost domin-debug

What it is

The debugger is two parts. The host is a second binary, domin-debug: the same interpreter as domin, built with the debugging hooks compiled in. The release binary contains none of them, so a normal run is not slowed by a debugger it does not have. The client is the VS Code extension: it starts domin-debug, talks to it over a loopback socket, and gives you the usual editor experience — gutter breakpoints, stepping, Variables, Call Stack, Watch, Debug Console.

Both binaries come in the DominScript download, next to each other in the folder you unpacked — call it the DominScript folder below. Nothing needs installing beyond that: the debugger is ready as soon as the folder is on your disk and you know where it is.

Install the extension

The extension is a single file in the DominScript folder, dominscript-0_0_631.vsix. VS Code and VSCodium install it the same way. From a terminal, in the DominScript folder:

code   --install-extension dominscript-0_0_631.vsix     # VS Code
codium --install-extension dominscript-0_0_631.vsix     # VSCodium

Without the code command on your PATH: open the Extensions view (Ctrl+Shift+X), click the … menu in its top-right corner, choose Install from VSIX… and pick the file. Either way, reload the window afterwards (Ctrl+Shift+P → Developer: Reload Window).

One setting matters: dominscript.binaryPath (Ctrl+,, search for it) should be the full path of domin in the DominScript folder — /home/anna/dominscript/domin, say, or C:/DominScript/domin.exe. The extension uses it for the live diagnostics, the red and yellow squiggles that appear when you save. If you have put the DominScript folder on your PATH, the default (domin) is enough.

Windows

Paths in settings and in launch.json are least trouble written with forward slashes, C:/DominScript/domin-debug.exe, and on Windows the binaries end in .exe.

The launch configuration

VS Code starts debuggers from a .vscode/launch.json in the folder you have open. Open the folder that holds your scripts (File → Open Folder), then either let VS Code write the file — Run and Debug view (Ctrl+Shift+D) → create a launch.json file → DominScript Debug — or create it yourself:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "dominscript",
            "request": "launch",
            "name": "Debug DominScript file",
            "program": "${file}",
            "debugBinary": "/home/anna/dominscript/domin-debug"
        }
    ]
}

The only line you must edit is debugBinary: the full path of domin-debug in the DominScript folder (C:/DominScript/domin-debug.exe on Windows). "program": "${file}" means “the file in the active editor”, so one configuration serves every script in the folder.

Optional fieldMeaning
"args"script arguments as a string array; the script reads them with os.Arg(i)
"cwd"working directory; defaults to the script’s folder. A plugin "…" path resolves relative to the .dom file either way, so the default is usually right
"port"a fixed TCP port on 127.0.0.1; by default a free one is picked
"stopOnEntry"true stops on the first statement of main before anything runs

Your first session

The examples that come with DominScript include a script made for this, debug_demo.dom: a loop that sums the squares 1…5 through a small function. If you would rather start from a file of your own, it is short enough to paste:

plugin "builtin:print";

i32 Square(i32 $X)
{
    i32 $Result = $X * $X;
    return($Result);
}

i32 main()
{
    i32 $I = 1;
    i32 $Sum = 0;
    string $Name[32] = "DominScript";
    while ($I <= 5)
    {
        $Sum = $Sum + Square($I);      // line 18 -- put the breakpoint here
        $I = $I + 1;
    }
    printf("%s: 1..5 the sum of the squares = %d\n", $Name, $Sum);
    return(0);
}

Run it plainly first so you know what to expect: domin debug_demo.dom prints 55. Then:

  1. Open the file in the editor (in the folder your launch.json is in).
  2. Click just left of the line number of line 18. A red dot appears: that is a breakpoint.
  3. Press F5 (or the green arrow in the Run and Debug view).

The script starts under domin-debug and stops on line 18 — the line is highlighted, the debug toolbar appears at the top of the window, and the panels on the left fill in.

What you see when stopped

Variables lists the locals of the current scope chain: in a function body the parameters first, then the locals in declaration order. Right now: $I = 1, $Sum = 0, $Name = "DominScript". Integers and floats show directly, strings in quotes, blobs with their length and a 16-byte hex preview. Arrays expand to their elements, a blob bound to a struct schema expands to its members ([e].tag when there are several elements), a plain blob to its bytes. Next to a blob or string there is a binary-view icon that opens the raw bytes in the VS Code Hex Editor, paged, not just the first 64.

Call Stack shows the script-function frames — only main at this point. Click a frame and the Variables panel switches to that frame’s locals, and so do Watch and the Debug Console: an expression is evaluated in the real scope of the selected frame.

Debug Console receives the script’s printf output, and accepts expressions: type $Sum + 1 and press Enter. Hovering a variable in the editor shows its value too.

Set Value. A local, an array element or a struct member can be overwritten while stopped: right-click it in Variables → Set Value, or type $I = 3 in the Debug Console. This is the one deliberate side effect the debugger allows; the right-hand side follows the purity rule below, and the language’s type rules still hold ($I = "abc" is an error).

Stepping

ActionKeyWhat it does
ContinueF5run to the next breakpoint
Step OverF10the next line; a call on the line is stepped over as a whole
Step IntoF11enter the script function called on this line
Step OutShift+F11run until the current function returns; the Debug Console then prints what it returned (Square returned int 4)
PauseF6a running script stops at its next statement, on whichever thread gets there first
RestartCtrl+Shift+F5the same script again; breakpoints are kept
StopShift+F5the script ends as on a runtime error, with the plugins shut down in order

Walk the sample once and the panels explain themselves. Stopped on line 18, press F11: you are inside Square, the Call Stack shows two frames, Variables shows $X = 1. F10 once: $Result = 1 appears. Shift+F11 brings you back to main with line 18 completed; F10 moves to line 19 with $Sum = 1. F5 runs to the next iteration — line 18 again, $I = 2. The sum goes 1, 5, 14, 30, 55. Breakpoints can be added and removed while the script runs; they take effect the next time the line is reached. Run past the last one and the printf line appears in the Debug Console, then the session ends.

Watch, conditions and the purity rule

The Watch panel takes expressions — $Sum + 1, $I * $I, $I <= 5 — and re-evaluates them at every stop. The same evaluator serves breakpoint conditions, logpoint text, watchpoints, hover and the Debug Console, and it follows one rule everywhere:

The purity rule

A debugger expression must be pure: it may read variables, apply operators, index, slice and cast, and it may call a plugin verb only if the plugin’s traits table declares that verb pure — no state, no I/O, no time. math.Sqrt($X), str.Len($Name), math.Pi() evaluate. print.Line("x"), anything that touches a file or a socket, a script function such as Square($I), and an assignment are refused with a message saying so.

The reason is that evaluating a watch must never change what the script would have done. The purity flag is the same one the loop machine relies on to run a verb inside a fused loop, so a plugin author states it once and both the optimizer and the debugger trust it.

A failed watch is hermetic: it reports its own error and leaves the script’s state — including a runtime error the script is about to stop on — exactly as it was.

Kinds of breakpoints

Right-click the gutter (Add Conditional Breakpoint…, or Edit Breakpoint… on an existing dot) and three fields are offered:

  • Expression — a conditional breakpoint: the script stops only when the expression is true, $I == 3. Pure plugin verbs are allowed here too: math.Sqrt($X) > 10.0. If the condition itself fails (an unknown variable, say), the debugger stops and writes the reason to the Debug Console rather than silently running on.
  • Hit Count — stop on the n-th hit: 5 (exactly the fifth), >=5, %3 (every third).
  • Log Message — a logpoint: does not stop; prints the text to the Debug Console with {expression} parts substituted, i={$I} sum={$Sum}.

Function breakpoints. The + in the Breakpoints panel takes a function name (Square) and stops on the function’s first statement.

Where a dot lands. A breakpoint on a blank, comment or continuation line is moved by the host to the line of the next statement — the dot moves to where the script will actually stop. A line with no statement after it stays an unverified hollow circle.

Runtime errors. The Runtime errors switch in the Breakpoints panel (on by default) stops the script on the statement that raises an error — i32 $Q = $S / $Z stops with “Division by zero” in the Exception bar, and Call Stack, Variables and Watch are live for a post-mortem. Continue (F5) and the script ends as it would have without a debugger.

Watchpoints. Right-click a variable, array element or member in Variables → Break on Value Change: the script stops at the statement after the one that changed the value, and the Debug Console shows the old and the new (watchpoint $S: 2 -> 6).

Included files. A breakpoint in a file pulled in with #include works as anywhere else; the Call Stack and error positions name the included file and its own line.

Callbacks and threads

Plugins that call the script back — worker, listener, timer, http, tcp, udp — run those callbacks on their own threads. The debugger follows them: a breakpoint inside a callback body stops the script on the callback’s thread, and while you are stopped every other script thread parks at its next statement. The Call Stack lists all of them; pick one and Variables, Watch and the console switch to it. Stepping moves the thread you are looking at while the others stay parked, so a step is deterministic.

A thread that is inside a native plugin call at that moment — blocked in tcpserver.Accept or timer.SleepMs — never reaches a parking point. The debugger does not pretend otherwise: it shows that thread from the snapshot taken when the call began, with the plugin verb’s name on top of its stack.

A small script shows it. The plugin line names the timer plugin by its path from the script; with the script next to the DominScript folder’s plugins directory it is this, otherwise adjust the path:

plugin "builtin:print";
plugin "plugins/timer/TimerPlugin";

callback i32 Tick(ref blob $Event)
{
    i64 $Marker = 4242;                 // breakpoint here
    printf("tick marker=%d\n", $Marker);
    return(1);
}

i32 main()
{
    i64 $TimerId = timer.Schedule("Tick", 150);
    timer.SleepMs(1200);
    printf("main done\n");
    return(0);
}

Put the breakpoint on the $Marker line, press F5, and the stop is reported on the callback thread, with main shown parked inside timer.SleepMs beside it.

Remote attach

The script can run on another machine — a Raspberry Pi, say — with VS Code on your desk. On the target:

domin-debug --debug-port 4711 --debug-bind 0.0.0.0 proba.dom
# "waiting for debugger client on 0.0.0.0:4711" -- the script does not start until the client says so

And in launch.json on the desk:

{
    "type": "dominscript",
    "request": "attach",
    "name": "Pi attach",
    "host": "192.168.1.50",
    "port": 4711,
    "localRoot": "${workspaceFolder}",
    "remoteRoot": "/home/pi/proba"
}

remoteRoot is the script’s working directory on the target; the sources must exist in the same layout under localRoot, because breakpoints cross as paths relative to the two roots. The script’s printf output stays in the target’s terminal.

If the connection drops

A closed VS Code, a crashed adapter or a lost link does not touch the script. domin-debug keeps whatever state it was in — a stopped script stays stopped, with its threads parked and its breakpoints in place; a running one keeps running and still stops at its breakpoints — and waits, with no time limit, for a new client on the same port (it says so on its stderr: debugger client gone … waiting for a new client). Attach again with the same configuration: the session opens on the kept stop (“Reconnected to a stopped script”), with live variables, and Continue carries on from there. Use QUIT — the Stop button — when you mean to end the script.

Trusted networks only

The debugger protocol is not authenticated. domin-debug listens on 127.0.0.1 unless you open it with --debug-bind, and it warns when you do. Anyone who can reach the port can read and set the script’s variables.

What it does not do

  • Step across a callback boundary. Step Into does not enter a callback that a plugin triggers — it runs on another thread. Put a breakpoint inside the callback instead.
  • Call script functions or side-effecting verbs from a watch or a condition (the purity rule above).
  • Track more than 32 script threads in a session.

How it is tested

The debugger is covered at three levels, all in the project’s own test suite (make test). The protocol: eleven DominScript clients drive domin-debug over TCP — a stepping sweep that walks a fixture with every statement kind with STEP, NEXT and OUT and pins the full line-by-line trace (so what “step” means is a contract), a type sweep that inspects a local of every type (bit fields and run-time signals included) through LOCALS, CHILDREN, EVAL, BYTES and SET, and a robustness client (a breakpoint on every line, a 2500-step burst, a client that vanishes mid-stop and a new one that reconnects to the kept stop, a second client). The VS Code adapter: Tests/dap_client.js hosts the adapter exactly as VS Code does and exercises every request it implements against the real host — 151 checks in eight sessions, including restart, the runtime-error stop, launches that cannot start, a reconnect after the adapter died at a stop, a breakpoint on a callback thread, a breakpoint in an included file and remote attach (it needs node; without node the suite reports a skip, not a failure). The unit level: the older eight clients pin the audit fixes, callbacks and threads, watchpoints, includes and hostile input. Since 0.0.627 a protocol line is sent as one segment, which made stepping about 25× faster.

Troubleshooting

  • The breakpoint dot is grey / nothing stops. Check that debugBinary points at a real domin-debug: the plain domin rejects --debug-port with a message that says exactly that. Check that the dot is on a statement line and that the code it sits in actually runs.
  • “cannot bind 127.0.0.1:PORT”. The port is taken. Remove "port" from launch.json (a free one is picked) or choose another.
  • “Failed to load plugin …”. A plugin "…" path resolves relative to the .dom file, and has no extension — the host adds .so or .dll. Is the script where the path expects it to be? Use an absolute path to the DominScript folder’s plugins directory, or move the script.
  • Diagnostics stopped after installing. Re-check dominscript.binaryPath and reload the window.
  • The script is already stopped, and Pause says so. Pause is for a running script; use Continue first.

If none of this matches what you see, the Debug Console’s last line is usually the one that explains it — the host reports every refused command in plain words.