|
| 1 | +# 0195 — The runtime thread owns its stack geometry; cljw does not run on the initial thread |
| 2 | + |
| 3 | +- **Status**: Proposed -> Accepted |
| 4 | +- **Date**: 2026-09-05 |
| 5 | +- **Author**: BuddhiLW |
| 6 | +- **Tags**: threads, stack, startup, wasm, ffi, jit, zwasm/D-584, ADR-0157, ADR-0193, ADR-0200, F-001, F-002, F-009 |
| 7 | + |
| 8 | +## Context |
| 9 | + |
| 10 | +### The trigger: a 14x per-call cost that exists only on thread 1 |
| 11 | + |
| 12 | +`wasm/call` on the default `.auto` (JIT) engine costs 16.5-18.9 us per call on |
| 13 | +Linux, against 392 ns on `:engine :interp` and 184 ns for a Clojure function |
| 14 | +call (`.dev/wasm_percall_findings.md`, `.dev/bench/ffi_boundary/`). The cause |
| 15 | +is upstream **zwasm/D-584**: `computeStackLimit` runs on every JIT invocation, |
| 16 | +and on Linux the stack-limit query for the process's INITIAL thread parses |
| 17 | +`/proc/self/maps` (~26 us). Any other thread answers from its thread |
| 18 | +descriptor. Measured in cljw itself, same process, same trivial `add` export, |
| 19 | +20k calls per cell (`engine_thread_matrix.clj`): |
| 20 | + |
| 21 | +| cell | ns/call | |
| 22 | +|------------------------|---------| |
| 23 | +| JIT, initial thread | 16547 | |
| 24 | +| JIT, worker thread | 1181 | |
| 25 | +| interp, initial thread | 399 | |
| 26 | +| interp, worker thread | 413 | |
| 27 | + |
| 28 | +zwasm v2.6.0 (the current pin) does not fix it. Per F-001 a zwasm-side issue |
| 29 | +is an upstream report, never a cljw-side edit of zwasm. The cljw-side |
| 30 | +question is where cljw runs, and the answer turns out to be a finished-form |
| 31 | +gap that predates the measurement. |
| 32 | + |
| 33 | +### The gap the trigger exposed: nothing owns the runtime thread's stack |
| 34 | + |
| 35 | +ADR-0157's self-calibrating guard raises a catchable `StackOverflowError` |
| 36 | +once `stack_base - @frameAddress()` exceeds `STACK_BUDGET_BYTES` (6 MiB, |
| 37 | +`src/eval/backend/vm.zig`, duplicated in `src/eval/backend/tree_walk.zig`). |
| 38 | +That budget is sound only by a prose convention: "8 MiB main / ~16 MiB worker |
| 39 | +stacks". The 8 MiB is `ulimit -s`, which cljw neither sets nor reads |
| 40 | +(`getrlimit` is absent from `src/`). Under `ulimit -s 4096` the guard trips |
| 41 | +at 6 MiB on a 4 MiB stack: a SIGSEGV today. Workers get their 16 MiB from |
| 42 | +`std.Thread.SpawnConfig.default_stack_size` through an empty `.{}` in |
| 43 | +`src/runtime/concurrency/spawn.zig`, an implicit inheritance nobody wrote |
| 44 | +down. The thread that runs every user program is the only thread whose stack |
| 45 | +geometry cljw does not choose. |
| 46 | + |
| 47 | +### Facts the design rests on (verified in source, see the DA report) |
| 48 | + |
| 49 | +- Nothing in cljw binds to the initial thread. The REPL's raw mode is per |
| 50 | + terminal fd (`src/app/repl/line_editor.zig`), there are no signal handlers |
| 51 | + or `sigaltstack`, `std.process.exit` is process-wide from any thread, and |
| 52 | + the GC's "main" is a ROLE (the unregistered thread reading its own TLS, |
| 53 | + `src/runtime/gc/root_set.zig`), not a thread id. The stop-the-world |
| 54 | + rendezvous counts registered workers minus the collector; the exit barrier |
| 55 | + joins only threads registered through `Thread.start` / `noteWorkerSpawned`. |
| 56 | +- The stack guard, the GC's conservative stack scan and the VM arena are all |
| 57 | + threadlocal, anchored on whatever thread first enters `eval`. |
| 58 | +- `-Dwasm` builds link libc: zwasm sets `.link_libc = true` on every module it |
| 59 | + exports. The gate binary is a glibc pthreads program; the non-wasm build is |
| 60 | + not. Both spawn threads through `std.Thread` either way. |
| 61 | +- `std.process.Init.arena` and `.environ_map` are pointers into the start |
| 62 | + frame, alive while the initial thread blocks in `join`; `gpa` and `io` are |
| 63 | + documented thread-safe. |
| 64 | +- wasm32-wasi (ADR-0193) is `builtin.single_threaded`; `std.Thread.spawn` is |
| 65 | + a compile error there and `spawn.zig` already carries the comptime shape. |
| 66 | +- There is no cold-start gate step; one `pthread_create` with a lazily |
| 67 | + committed 16 MiB reserve is ~20-60 us, once per process. |
| 68 | + |
| 69 | +## Decision |
| 70 | + |
| 71 | +1. **One module owns runtime-thread geometry: |
| 72 | + `src/runtime/concurrency/runtime_thread.zig`** (namespace-neutral, F-009). |
| 73 | + It defines `RUNTIME_STACK_BYTES` (16 MiB), the `std.Thread.SpawnConfig` |
| 74 | + built from it, and `STACK_BUDGET_BYTES` (6 MiB). `spawn.zig` spawns |
| 75 | + workers with that config, so the runtime thread and every worker have |
| 76 | + identical geometry (Single-Source Lever). `vm.zig` and `tree_walk.zig` |
| 77 | + read the budget from here and each carries a `comptime` assertion that |
| 78 | + `STACK_BUDGET_BYTES + @sizeOf(VmArena) + 1 MiB <= RUNTIME_STACK_BYTES`, |
| 79 | + replacing the prose margin. The TLS term matters: under glibc pthreads the |
| 80 | + static TLS block (the threadlocal `VmArena`, roughly 1 MiB) is carved out |
| 81 | + of the thread's stack allocation. |
| 82 | +2. **`main.zig` runs `cli.dispatch` on a spawned runtime thread**: |
| 83 | + `runtime_thread.runMain(cli.dispatch, .{init})` spawns with that config, |
| 84 | + joins, and returns the callback's error so `wrapMain` reports it exactly |
| 85 | + as before. On `builtin.single_threaded` targets it is a direct call. On |
| 86 | + spawn failure (a container with a tiny `RLIMIT_AS`) it degrades to a |
| 87 | + direct call after one stderr line, the same graceful-disable posture zwasm |
| 88 | + takes for its own stack probe. The entry thread never registers a |
| 89 | + `ThreadGcContext` and never touches `noteWorkerSpawned`, so it is not a |
| 90 | + worker to the exit barrier or the safepoint; a unit test pins that, along |
| 91 | + with: the callback observes a different `std.Thread.getCurrentId()` than |
| 92 | + the caller, and a returned error propagates. |
| 93 | +3. **The engine default is NOT decided here.** After (2) the JIT's per-call |
| 94 | + overhead on Linux drops from ~16.1 us to ~0.8 us over interp, which moves |
| 95 | + the compute break-even from ~17 us of wasm body to ~0.8 us (using the |
| 96 | + 15.5x body speedup from `bench/wasm_jit_vs_interp.sh`). That is the |
| 97 | + strongest argument for keeping `.auto`, but cljw's own **D-585** (void |
| 98 | + exports of some arities TRAP on the JIT and `.auto` cannot downgrade) is a |
| 99 | + correctness fact that outranks it. `[CLJW-WASM-ENGINE-DEFAULT]` decides |
| 100 | + with D-585 ranked above the 2.9x; this ADR only removes the 14x. |
| 101 | +4. **zwasm/D-584 gets a cljw ledger row** with the barrier "zwasm memoises |
| 102 | + `computeStackLimit` per thread (re-check `engine/codegen/shared/entry.zig` |
| 103 | + at every pin bump)". The measurement device is |
| 104 | + `.dev/bench/ffi_boundary/engine_thread_matrix.clj`, run on demand; a |
| 105 | + timing ratio in the gate would be a flake generator (test_taxonomy), so |
| 106 | + there is deliberately no gated gap-lock. When the barrier dissolves the |
| 107 | + trigger paragraph above is history and the geometry decision stands on |
| 108 | + its own. |
| 109 | +5. **Residual attribution, corrected.** Of the 1181 ns a worker-thread JIT |
| 110 | + call costs, ~570 ns is STILL zwasm/D-584 (the glibc query on a non-initial |
| 111 | + thread, zwasm ADR-0209). zwasm/D-585 (export re-resolved by name per call) |
| 112 | + plus cljw's own double resolve in `wasmCallFn` (`exportSig` then `invoke`, |
| 113 | + both by name) is the remaining ~200 ns. The upstream report, when filed |
| 114 | + (user nod required, `[ZWASM-PERCALL-TRACK]`), asks for per-thread |
| 115 | + memoisation and notes the musl hygiene point below. |
| 116 | + |
| 117 | +## Alternatives considered |
| 118 | + |
| 119 | +The Devil's-advocate report (`private/notes/adr-0195-da.md`, fresh context, |
| 120 | +read-only) is reflected verbatim. Its recommendation (Alt B) is the decision |
| 121 | +above; its three corrections to the draft (geometry as the reason, the |
| 122 | +residual split, the libc link model) are absorbed. |
| 123 | + |
| 124 | +### Leading finding |
| 125 | + |
| 126 | +No F-NNN conflict. F-001's live text (2026-08-12 entry) forbids editing zwasm |
| 127 | +and pinning a non-release; a zwasm-side finding "is now an ordinary upstream |
| 128 | +report". The 2026-06-05 clause "no cljw-side code change to work around a |
| 129 | +zwasm-side issue" is explicitly RETIRED by that entry. The draft edits no |
| 130 | +zwasm code and keeps the v2.6.0 pin, so it is F-001-legal. |
| 131 | + |
| 132 | +Three corrections the draft must absorb before Accepted, or it is the |
| 133 | +Workaround smell wearing an ADR: |
| 134 | + |
| 135 | +1. Its only stated rationale evaporates the day upstream lands a threadlocal |
| 136 | + memo in `stack_limit.zig`. After that, the entry hop is vestigial unless |
| 137 | + the ADR carries an independent finished-form reason. One exists (Alt B): |
| 138 | + the runtime thread's stack geometry is today owned by nothing. ADR-0157's |
| 139 | + 6 MiB guard is sound only by the prose convention "8 MiB main / 16 MiB |
| 140 | + worker"; `ulimit -s 4096` makes it a SIGSEGV today. Frame the ADR on |
| 141 | + geometry ownership with D-584 as the trigger, not the reason. |
| 142 | +2. The residual attribution is wrong. zwasm ADR-0209 measures the |
| 143 | + worker-thread glibc query at 561-578 ns per call. Of the post-fix 1181 ns, |
| 144 | + about 570 ns is STILL D-584; D-585 plus cljw's own double resolve is only |
| 145 | + ~200 ns. The upstream report must ask for per-thread memoisation, which |
| 146 | + would put a trivial JIT call near 600 ns (~1.5x interp), not 2.9x. |
| 147 | +3. cljw `-Dwasm` links libc. zwasm sets `.link_libc = true` on every module it |
| 148 | + exports, so the gate binary is a glibc pthreads program. The brief's "does |
| 149 | + not appear to link libc" holds only for the non-wasm build. |
| 150 | + |
| 151 | +Also missing from the draft: no cljw ledger row references D-584 at all, and |
| 152 | +no gap-lock exists for it. Without both, the rationale cannot be retired when |
| 153 | +upstream fixes it. |
| 154 | + |
| 155 | +### Alt A: smallest-diff |
| 156 | + |
| 157 | +Keep the runtime on thread 1. Document `@(future ...)` (measured 12.5x) and |
| 158 | +`{:engine :interp}` in the wasm docs; add the cljw `exportSig` cache; file the |
| 159 | +upstream issue. Better than the draft: zero structural change, zero risk to |
| 160 | +exit/REPL paths, no comptime branch for wasm32-wasi. Breaks: the `.auto` |
| 161 | +default keeps choosing an engine ~41x slower than interp for trivial calls on |
| 162 | +the platform the gate runs on, and the user has to know a threading trick to |
| 163 | +get the documented engine's speed. This is the Smallest-diff bias smell and |
| 164 | +fails F-002; rejected. |
| 165 | + |
| 166 | +### Alt B: finished-form-clean (recommended, taken) |
| 167 | + |
| 168 | +Own runtime-thread geometry as a single-sourced property, with the |
| 169 | +initial-thread hop as a consequence. New `runtime_thread.zig` holding ONE |
| 170 | +16 MiB `SpawnConfig`, consumed by both the entry hop and `spawn.zig`; |
| 171 | +`STACK_BUDGET_BYTES` shared with a comptime assertion against the stack size |
| 172 | +minus the static TLS reserve; `runMain` degrading to a direct call on |
| 173 | +`single_threaded` and on spawn failure; a ledger row for the upstream cost; |
| 174 | +the residual paragraph corrected. Better than the draft: survives the |
| 175 | +upstream fix without becoming a fossil; removes the `ulimit -s` dependence of |
| 176 | +ADR-0157's calibration; turns a prose invariant into a compile-time one; the |
| 177 | +zwasm JIT stack probe gets a precise pthread bound for the runtime thread |
| 178 | +instead of the top-minus-rlimit estimate, so a wasm stack-overflow trap is |
| 179 | +deterministic. Breaks: a larger diff; unit tests still run on thread 1 (they |
| 180 | +do not enter `main.zig`), which is fine. Diff size is not a project |
| 181 | +constraint; F-002 says take it. |
| 182 | + |
| 183 | +### Alt C: wildcard |
| 184 | + |
| 185 | +Symbol interposition: cljw exports its own `pthread_getattr_np` that memoises |
| 186 | +per thread and delegates to glibc via `dlsym(RTLD_NEXT)`. Fixes the cost on |
| 187 | +thread 1 AND on every worker. Breaks: the Workaround smell in pure form |
| 188 | +(patching glibc underneath zwasm), glibc-only, silently wrong under static |
| 189 | +linking, invisible to zwasm's own bench, and it removes the pressure that |
| 190 | +gets the upstream memo landed. Rejected; recorded so the section shows it was |
| 191 | +seen. |
| 192 | + |
| 193 | +### Attack answers (condensed; file:line citations in the report) |
| 194 | + |
| 195 | +1. Initial-thread assumptions: none found. termios is per fd; no |
| 196 | + `sigaction`/`sigaltstack`/`getCurrentId`/`main_thread` in production |
| 197 | + `src/`; `std.process.exit` is process-wide from any thread. One visible |
| 198 | + change: an error RETURNED by `dispatch` (not exited) currently prints the |
| 199 | + failing thread's error-return trace; re-returned from `main` it prints the |
| 200 | + initial thread's one-frame trace. `Init.arena`/`environ_map` point into |
| 201 | + the start frame, alive while the initial thread blocks in `join`. |
| 202 | + `exitBarrier` joins `rt.user_threads` then waits on `live_workers`; the |
| 203 | + dispatch thread touches neither. root_set's "main" is a role. safepoint's |
| 204 | + rendezvous target is a registered count, not a thread id. wasm32-wasi: |
| 205 | + `std.Thread.spawn` is `@compileError` under `single_threaded`; the comptime |
| 206 | + shape exists in `spawn.zig`. nrepl spawns no threads; `future`/agent/Thread |
| 207 | + workers become children of the runtime thread, which changes nothing they |
| 208 | + observe. |
| 209 | +2. Stack size: no `getrlimit` in cljw `src/`. `ulimit -s` today bounds thread |
| 210 | + 1's growth; after the change it bounds nothing in cljw. The hazard is |
| 211 | + INVERTED: today a small ulimit defeats the 6 MiB guard; after, the guard is |
| 212 | + sound regardless. Upstream hygiene: zwasm's `stack_limit.zig` parses |
| 213 | + `[stack]` for ANY thread on non-glibc Linux, which is thread 1's mapping, |
| 214 | + so on a musl/static build the JIT probe on a non-initial thread would |
| 215 | + compare SP against thread 1's bounds. cljw has no musl target; a musl |
| 216 | + target re-opens this, and the upstream report should include it. |
| 217 | +3. Dedicated wasm worker + per-call hop: never better. A cross-thread futex |
| 218 | + handoff is two context switches, 5-20 us, against a 1 us threshold, and a |
| 219 | + host import calling back into Clojure would run on a thread with no |
| 220 | + `ThreadGcContext`, invisible to stop-the-world. It re-creates the D-244 #4 |
| 221 | + territory for a loss. |
| 222 | +4. Flip the default to `:interp`? On perf alone, no: the fix moves the |
| 223 | + compute break-even by ~20x. But cljw's open D-585 row is a correctness |
| 224 | + fact perf does not answer. Keep the default OUT of this ADR (Decision 3). |
| 225 | +5. Startup cost: no `cold_start` run step exists; the only perf-cliff guard |
| 226 | + is `assert_e2e_releasesafe`. One spawn is under 1.5% of cold start; across |
| 227 | + the ~3200-spawn e2e suite that is +0.1-0.2 s total. Applying it to |
| 228 | + non-wasm builds is the POINT (uniform geometry), not a cost. |
| 229 | + |
| 230 | +## Consequences |
| 231 | + |
| 232 | +- Positive: JIT `wasm/call` on Linux drops ~14x with no zwasm change and no |
| 233 | + user-visible API; macOS unaffected (zwasm/D-584 does not exist there). |
| 234 | + `@(future ...)` stops being a needed workaround. |
| 235 | +- Positive: ADR-0157's margin becomes a compile-time fact instead of an |
| 236 | + `ulimit` assumption; every cljw thread has the same, chosen stack. |
| 237 | +- Positive: the zwasm JIT stack probe sees a precise pthread bound on the |
| 238 | + runtime thread, so a guest stack-overflow trap is deterministic. |
| 239 | +- Negative: one thread spawn per process start (tens of microseconds, once); |
| 240 | + an error returned (not exited) from `dispatch` loses the worker's |
| 241 | + error-return trace in favour of the initial thread's one-frame trace. |
| 242 | +- Neutral: exit codes, `std.process.exit` sites and panics behave as before; |
| 243 | + the runtime remains single-threaded; unit tests still run on thread 1. |
| 244 | +- Follow-ups kept separate: `[CLJW-WASM-ENGINE-DEFAULT]` (Decision 3), |
| 245 | + `[CLJW-WASM-EXPORTSIG-CACHE]` (the cljw half of the residual), |
| 246 | + `[CLJW-WASM-BENCH-BLIND]` (the crossing-dominated bench axis). |
| 247 | + |
| 248 | +## Affected files |
| 249 | + |
| 250 | +- `src/main.zig`: `runtime_thread.runMain(cli.dispatch, .{init})`; test |
| 251 | + aggregator imports the new module. |
| 252 | +- `src/runtime/concurrency/runtime_thread.zig` (new): the geometry SSOT, |
| 253 | + `runMain`, unit tests. |
| 254 | +- `src/runtime/concurrency/spawn.zig`: workers spawn with the shared config. |
| 255 | +- `src/eval/backend/vm.zig`, `src/eval/backend/tree_walk.zig`: budget read |
| 256 | + from the SSOT; comptime margin assertion; the prose margin comment retired. |
| 257 | +- `.dev/debt.yaml`: the zwasm/D-584 tracking row. |
| 258 | +- `.dev/wasm_percall_findings.md`, `.dev/bench/ffi_boundary/README.md`: |
| 259 | + residual attribution corrected; after-numbers recorded when measured. |
| 260 | +- `CHANGELOG.md`: user-visible entry (the per-call cost on Linux). |
| 261 | + |
| 262 | +## References |
| 263 | + |
| 264 | +- `.dev/wasm_percall_findings.md`; GitHub issues #13, #14, #15. |
| 265 | +- zwasm ADR-0209, zwasm/D-584, zwasm/D-585 (`~/PP/referential-projects/zwasm`). |
| 266 | +- ADR-0157 (self-calibrating stack guard), ADR-0193 (wasm32-wasi target), |
| 267 | + ADR-0200 (JIT adoption), D-488 (the `.auto` flip), cljw D-585 (JIT |
| 268 | + zero-result gap, ADR-0192). |
| 269 | +- Kanban `[CLJW-WASM-WORKER-THREAD]` (`20260905012150-05dc6702`), |
| 270 | + `[CLJW-WASM-ENGINE-DEFAULT]` (`20260905005755-11afa056`), |
| 271 | + `[ZWASM-PERCALL-TRACK]` (`20260905005855-3d34a890`). |
| 272 | +- `.dev/principle.md`: Workaround smell, Smallest-diff bias smell, |
| 273 | + Cycle-budget defer smell (all three named in the DA report). |
0 commit comments