About the worker/coordinator architecture¶
utest runs each test file in a separate ucode subprocess. Understanding why, and how the two sides communicate, makes it easier to reason about failures, timeouts, and reporter output.
Why subprocesses?¶
ucode has no threads and no exception isolation between modules loaded into the
same interpreter instance. If a test file crashes — an uncaught exception, a
die() call outside a test body, a corrupt global — the crash would take the
entire test run with it.
Running each file in its own subprocess means the blast radius of any single
failure is bounded to that file. The coordinator receives a FATAL event, marks
the file as errored, and continues with the remaining files. A crash in
10_standard_shim_test.uc cannot prevent 03_aftereach_guarantee_test.uc from
running.
Subprocess isolation also gives each test file a clean address space. Global variables, imported module state, and any monkey-patching done by one file cannot bleed into another file's execution.
How the coordinator discovers files¶
The coordinator parses each bundle argument from the command line ([Name:]path),
expands any directory paths using the active glob pattern, and sorts the resulting
file list for stable ordering.
Before spawning any workers, the coordinator runs the shim generator, which
produces a shim file for every module listed in config.mocks and places them in
a temporary directory. That directory is then prepended to every worker's -L
module search path so the shims shadow the real modules. See
Reference: Source layout for which
modules implement each phase.
How workers are spawned and how output flows back¶
Each worker is a fresh ucode invocation:
ucode -L <shim_dir> -L <src_dir> -L <cwd> \
src/utest/runner/worker/bootstrap.uc \
'{"file":"...","filter":"...","bundle":"...","seed":...,"mocks":["fs","uci"]}'
bootstrap.uc receives the JSON argument. Before loading the test file it installs a require() override that routes calls to declared mock modules through their active global proxy (if any), complementing the shim mechanism that covers import statements. It then loads the test file with loadfile() and calls worker_runner.run_tests(). The worker reporter in
src/utest/runner/worker/reporter.uc writes JSON objects to stdout — one per
event, terminated by a newline. The coordinator reads those lines and routes them
to the coordinator-side reporter.
sequenceDiagram
participant C as Coordinator
participant W as Worker process
C->>W: spawn ucode -L … bootstrap.uc '{"file":…}'
W->>C: {"event":"SUITE_START","count":N}
loop for each test
W->>C: {"event":"TEST_RESULT","status":"PASS|FAIL|…"}
end
alt normal exit
W->>C: {"event":"SUITE_END","duration_ms":…}
else uncaught exception or load failure
W->>C: {"event":"FATAL","error":"…"}
end
Sequential vs parallel executor¶
The executor choice is made by src/utest/runner/executor.uc based on the
jobs configuration key:
Sequential (jobs: 1, the default): opens each worker with fs.popen(),
reads lines from the pipe synchronously, closes the pipe before starting the
next worker. Output arrives in the order tests run. No temporary files are
involved.
Parallel (jobs: N): drives the whole worker fleet through the ucode
uloop event loop — there is no polling loop and no fixed wake interval. It
starts up to N workers with uloop.process, and each worker's stdout is
redirected to its own file under $run_dir/workers/. When a worker exits,
uloop invokes its process callback; the coordinator reads that worker's file
once, in full, feeds it to the shared decoder, then starts the next queued
file. Because a worker's entire output is decoded in a single callback, one
suite's events never interleave with another's. A per-worker uloop.timer
enforces the wall-clock timeout (default 60 seconds, configurable via timeout
in utest.config.uc); on expiry the worker is killed and its exit callback
finalizes with whatever partial output reached the file.
Because the parallel executor is built entirely on uloop, jobs > 1 requires
the ucode uloop module to be present — there is no polling fallback. Running
-j 1 (the default) never touches uloop, so the module stays a soft
dependency for minimal images.
The trade-off: sequential is simpler, streams output live line-by-line, and
needs no uloop; parallel reduces total wall time for slow suites but decodes
each suite only at worker exit (so its output is contiguous-per-suite rather
than live-per-line).
Why the timeout kill targets the process group¶
A hung worker is killed on timeout, but a test may have spawned children of its
own (a popen'd daemon, a background system(... &)). Killing only the worker
PID would leave those children alive: under the sequential executor a surviving
child holds the read pipe's write end open, so the blocking read never sees EOF
and the run stalls; under the parallel executor the child simply lingers as an
orphan.
To prevent this, each worker is made the leader of its own process group with
setsid, and the timeout kill signals the negative PID — the whole group,
not just the worker. The sequential watchdog sends SIGTERM (its wait then
reports exit 143, which the executor matches exactly to distinguish a timeout
from a crash); the parallel executor sends SIGKILL (it keys the diagnostic on
a timed_out flag, so it does not need a distinguishable exit code). The
signals differ but the outcome is identical: a worker installs no handler, and
its whole subtree is terminated.
flowchart TD
A["executor.uc"] -->|"jobs: 1 (default)"| B["Sequential executor"]
A -->|"jobs: N"| C["Parallel executor (uloop)"]
B --> B1["fs.popen(setsid worker)"]
B1 --> B2["read lines synchronously\n(shell watchdog: sleep → SIGTERM -PGID)"]
B2 --> B3["close pipe · exit 143 ⇒ timeout"]
B3 --> B4{"more files?"}
B4 -->|yes| B1
B4 -->|no| DONE["done"]
C --> C1["uloop.process(setsid worker)\nstdout → workers/out.N\n+ per-worker uloop.timer"]
C1 --> C2["uloop.run()\n(event loop, no polling)"]
C2 --> C3{"worker exit\ncallback?"}
C3 -->|"timer fired first"| C5["SIGKILL -PGID\nset timed_out"]
C5 --> C3
C3 -->|yes| C4["read worker file once\nfeed decoder · finalize"]
C4 --> C6{"more queued?"}
C6 -->|yes| C1
C6 -->|no| DONE
The FATAL event¶
FATAL is the "something went catastrophically wrong" signal. It can originate from four places:
- The worker itself (
bootstrap.uc): ifloadfile()fails, or if the test file throws an uncaught exception during the module-level evaluation phase, or ifsetup()/teardown()throws. The worker prints the FATAL JSON line and exits with code 1. - Timeout (either executor, coordinator side): a worker that exceeds its
wall-clock timeout is killed (its process group — see above) and a FATAL is
synthesised from the shared
terminal_fatallogic. - Spawn failure (coordinator side): if a worker exits without having
produced any valid protocol output — for example because
ucodewas not found onPATH, or the worker crashed before writing anything — the coordinator reports whatever landed in the worker's output file as a FATAL. This distinguishes a spawn failure (no events) from a suite that ran and reported normally. - Run interrupted (parallel executor, coordinator side): if the whole run
is cut short (e.g.
^C) before every worker finished, the executor emits oneaggregateFATAL standing for the truncation, so the summary is honest and the exit code is non-zero.
After a FATAL, that suite produces no TEST_RESULT or SUITE_END events. Reporters count fatals separately from errors and failures, and the run exits with a non-zero code if any fatals occurred.