Skip to content

Commit ed2d0a2

Browse files
committed
docs(adr): ADR-0195, the runtime thread owns its stack geometry; D-586 tracks zwasm/D-584
Smell-audited: 3: a new ADR with the mandatory fresh-context Devil's-advocate fork reflected verbatim (Alt B taken per F-002; Alt A = Smallest-diff bias, Alt C = Workaround smell). Decision: one module (runtime/concurrency/runtime_thread.zig) owns RUNTIME_STACK_BYTES, the SpawnConfig and STACK_BUDGET_BYTES for the entry hop AND the workers, with a comptime margin assertion replacing ADR-0157's prose '8 MiB main / 16 MiB worker'; main.zig runs cli.dispatch on a spawned runtime thread. zwasm/D-584 (16.5 us per JIT wasm/call on the initial thread on Linux) is the trigger, tracked as D-586 with an upstream barrier, not the reason. The engine default is kept out (CLJW-WASM-ENGINE-DEFAULT, cljw's own D-585 ranked above the residual 2.9x). Implementation follows on this branch behind a smoke; the branch merges into staging after v1.14.0 (PR #16) is cut.
1 parent 10f0b61 commit ed2d0a2

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,41 @@
2424
# md-table-align no longer pads it to ~800 KB of whitespace.
2525

2626
active:
27+
- id: "D-586"
28+
status: |-
29+
open (2026-09-05, ADR-0195 trigger). UPSTREAM: zwasm/D-584. zwasm's JIT
30+
calls `computeStackLimit` on EVERY invocation, and glibc's stack query
31+
for the process's INITIAL thread parses /proc/self/maps: 16.5-18.9 us per
32+
`wasm/call` on Linux against 392 ns on `:engine :interp` and 184 ns for a
33+
Clojure fn call (.dev/wasm_percall_findings.md; issues #13/#14/#15).
34+
Measured 2026-09-05, engine_thread_matrix.clj, 20k calls per cell:
35+
JIT initial 16547 ns, JIT worker 1181, interp 399/413. NOT fixed in
36+
zwasm v2.6.0 (computeStackLimit still per invocation on upstream main:
37+
engine/codegen/shared/entry.zig 248, 272; entry_buffer_write.zig 86).
38+
cljw's answer is ADR-0195 (the runtime runs off the initial thread; the
39+
geometry decision stands on its own, this row only tracks the upstream
40+
cost). Residual on a non-initial thread: ~570 ns of the 1181 is STILL
41+
this row (zwasm ADR-0209 measures the glibc query at 561-578 ns there);
42+
zwasm/D-585 + cljw's own double name-resolve in wasmCallFn is ~200 ns.
43+
Not filed upstream: their tracker already carries it; a short report
44+
with the embedder numbers needs a user nod ([ZWASM-PERCALL-TRACK]).
45+
category: "wasm"
46+
barrier: |-
47+
zwasm memoises `computeStackLimit` per thread (a threadlocal cache in
48+
engine/codegen/shared/stack_limit.zig, or the query hoisted out of the
49+
per-invocation path). Re-check at every pin bump by reading upstream
50+
engine/codegen/shared/entry.zig; the measurement device is
51+
`./zig-out/bin/cljw .dev/bench/ffi_boundary/engine_thread_matrix.clj`
52+
(on demand, never gated: a timing ratio in the gate is a flake generator,
53+
test_taxonomy.md). When the barrier dissolves, retire ADR-0195's trigger
54+
paragraph (the decision survives) and re-measure the residual so
55+
[CLJW-WASM-ENGINE-DEFAULT] can be re-decided on the new numbers.
56+
first_raised: "2026-09-05"
57+
last_reviewed: "2026-09-05"
58+
refs: |-
59+
ADR-0195; ADR-0209 (zwasm); .dev/wasm_percall_findings.md;
60+
.dev/bench/ffi_boundary/README.md; kanban [CLJW-WASM-WORKER-THREAD]
61+
20260905012150-05dc6702, [ZWASM-PERCALL-TRACK] 20260905005855-3d34a890.
2762
- id: "D-585"
2863
status: |-
2964
open (2026-08-22, found landing ADR-0192). zwasm's JIT MISCOMPILES a
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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

Comments
 (0)