Skip to content

Release v1.15.4 from BuddhiLW/main - #20

Merged
BuddhiLW merged 95 commits into
clojurewasm:mainfrom
BuddhiLW:main
Oct 23, 2026
Merged

BuddhiLW merged 95 commits into
clojurewasm:mainfrom
BuddhiLW:main

Conversation

@BuddhiLW

Copy link
Copy Markdown
Contributor

Brings clojurewasm/ClojureWasm main up to v1.15.4 (fork tag d973510).

  • Perf: hot core library moved to Zig primitives (ADR-0205): chunked vector seqs, multi-coll map steps, mapv through a transient.
  • Eval: per-call windows off the native stack, frame_slots serialized.
  • GC: heap-return test sized for the shared suite image; bb-mcp memory fixes.
  • CI: changelog_reachability accepts a mirror that lacks an intermediate tag, and the race with the release tag.

See CHANGELOG.md [1.15.4].

BuddhiLW and others added 30 commits October 21, 2026 23:30
…p cells

[CLJW-WASM-CALL-22X] reported core wasm/call 22x slower per call than
component-call: 14.2 us for (wasm/call h "sum3" 1.5 2.5 3.5) against
0.65 us for component-call "echo-f64", measured on v1.10.18. That gap does not
reproduce on staging 596ebac.

Before/after, ReleaseSafe -Dwasm, ns per call, min of 7 trials x 5000 calls,
two runs each, load average 11-15:

  cell                         v1.10.18   staging (run 1 / run 2)
  wasm/call sum3, :auto           14200     1040 / 838
  wasm/call sum3, :jit fuel 0         -      804 / 838
  wasm/call sum3, :interp             -      386 / 561
  component-call echo-f64           650     1166 / 1064

This bench, n=20000, 7 trials, median (min): core-f64x3 1396 (1035),
component-f64 1584 (1049), fn-call 194 (150).

Core wasm/call is now at parity with component-call on :auto and :jit, and
about 2x faster on :interp. Two earlier commits closed the gap:

- c2ff700 (ADR-0195) runs the program on a spawned runtime thread. zwasm/D-584
  made every JIT invoke on the process's initial thread parse /proc/self/maps
  through pthread_getattr_np, which cost about 15 us per call. The 14.2 us
  figure matches that per-call cost.
- 41d3444 caches the resolved export signature per handle. Before it, the JIT
  arm re-parsed the whole module on every call.

The remaining per-call cost is upstream: zwasm/D-585 still resolves the export
by name inside invoke. On the cljw side I also tried moving the args and
results buffers in callExport from two rt.gpa alloc/free pairs to stack
buffers. Across two interleaved runs the change was within noise (auto 918/991
against 1040/838 before), so it is not part of this commit.

Bench changes:

- Two new cells, core-f64x3 and component-f64, keep the two surfaces measured
  side by side, so a future divergence shows up.
- Each cell now loads its own instance. On staging an :interp handle traps
  when it is called from a second thread after many calls on its first thread
  (5000 calls on the runtime thread, then one call inside a future). :jit and
  :auto handles do not. The interp-worker cell reused interp-main's handle, so
  the bench aborted with "trapped on the interp engine". That cell now loads
  its instance on the worker, inside the trial. The trap itself is reported for
  a separate card.

No src/ change.
…reader (ADR-0200 Layer B)

clojure.edn/read and read-string ran the general Clojure reader, so input
clj's EdnReader rejects was accepted. A `Dialect` flag on the shared
Tokenizer (set through Reader.initDialect from edn.zig) now selects the
EDN surface, as ADR-0200 decided, instead of a second reader:

- `` ` `` `~` `@` are invalid leading and invalid constituent characters
  (`a@b`, `[a`b]` throw, as in clj; core read-string keeps splitting them);
- `'` is a symbol constituent that may start a symbol: `'a` is the symbol
  `'a`, `'(1)` reads `'`;
- `#'` `#(` `#"` `#=` `#?` `#?@` `#!` `#<` are "No dispatch macro", `::kw`
  and `#::{}` are invalid tokens;
- `#ns.Rec{}` resolves through :readers / :default only (new LiftChecks
  `.edn`), never as a record constructor;
- a nil source reads as nil for both arities.

ADR-0200 A4 for data reads: a `#_` target is lifted the moment it is read
(Reader.discard_hook, run inside edn.zig's binding frame), so a discarded
tagged literal runs its reader or raises "No reader function", and a
discarded duplicate-key map raises, in source order. `[#_ #my/boom 0 42]`
now runs boom, as in clj.

Residual (card CLJW-READER-DUP-RESIDUALS order class): in
`[{:a 1 :a 2} #_ #foo 1]` clj raises the IAE first, cljw the missing tag,
because the enclosing collection's duplicate check runs after its slots.

Oracle: bb scripts/clj_diff_sweep.clj, 59/59 against JVM clj 1.12. Corpus
test/diff/clj_corpus/edn_strictness.txt (59); bb scripts/corpus_regression.clj
5474/5474.

Smell-audited: 2: dialect flag on the shared tokenizer per ADR-0200, discard hook is a read-time seam owned by the data-read caller, no fork of the reader
….ffi library

[CLJW-EXAMPLE-ZIG-NATIVE] The only Zig example was polyglot/zig/numeric.zig,
scalars over wasm. docs/examples/zig-native/kernel.zig exports what Zig does
well and Clojure does not: a 10,000-prime table computed at compile time, a
StaticStringMap keyword table, an @vector(4, f64) dot product, @popcount and
@bitreverse, and std.crypto BLAKE3. zig_native.clj calls each one through
wasm/load (committed 47 KB kernel.wasm, guest-owned buffers filled with
wasm/mem-write!) and through cljw.ffi (the same source as a shared library,
skipped when not built).

test/e2e/phase16_zig_native.sh gates both: the committed wasm alone, then both
artifacts rebuilt from the header recipe. Verified against v1.15.3:
PASS zig-native-committed, PASS zig-native-rebuilt.

The README carries the measured per-call costs of each route and the known
limits found while writing it: wasm i64 crosses exactly only within 2^47
(CLJW-WASM-I64-FULL-RANGE), :jit call cost grows with module size
(CLJW-WASM-JIT-PERCALL-SCALES). The dot leg moves to primitive arrays when
CLJW-FFI-PRIM-ARRAY-BYTES lands.

Smell-audited: 0: new example directory and one gate step, no source change
zwasm's interpreter cached the first calling thread's native-stack low
limit on the instance's Runtime, so a handle warmed on the runtime thread
and then called from a future trapped CallStackExhausted. Pin zwasm
fix/interp-thread-trap 79f3d63a, which resolves the limit per calling
thread. New e2e phase16_wasm_thread_hop covers :interp, :jit and :auto,
warmed 1x and 5000x, plus a worker-first call; registered in run_all.sh.

Card: CLJW-WASM-INTERP-THREAD-TRAP
Smell-audited: 0: dependency pin bump plus e2e, no cljw source change
…the native route measured

The page still listed subvec as the last catastrophic gap (~7500x slower).
O-059 (2026-08-22) fixed it: 2000 ops went from 9215 ms to 0.77 ms, against
1.06 ms on JVM clj 1.11.1, so all four catastrophic gaps are now closed and
subvec joins the view table.

New, measured 2026-10-22 on v1.15.3 against warm JVM Clojure 1.12.6 (2000
warmup calls, best of 7, same loaded host): what is still slow is per-element
work in tight loops, 13x (subvec, constant time on both) to 26x (dot of 1000
doubles via reduce/map), 29x (loop/recur + nth) and 74x (byte-array from an
8000-element vector). A section on handing hot kernels to compiled code gives
the same-day cljw.ffi numbers (dot 1.85 ms in Clojure, 12.2 us in Zig; per-call
costs of ffi and wasm) and links docs/examples/zig-native.

Also drops the em-dashes the page carried.

Smell-audited: 0: docs only, measured numbers replace a stale claim
check_e2e_count failed the gate (323 counted, baseline 322). The script runs
the zig toolchain to rebuild the example's wasm and shared library, which a
cljw deftest cannot do (no subprocess), so it carries the
e2e-process-boundary marker instead of raising the baseline.

Smell-audited: 0: one marker line in a test script
check_e2e_count freezes the bash e2e tier at 322 scripts; new coverage is a
clojure.test suite unless it needs a process boundary, and this one does not.
suites/wasm-thread-hop-test runs the same matrix (:interp, :jit, :auto,
warmed 1x and 5000x, then a future, then back; plus a worker-first handle):
14 assertions pass on this branch's build, 2 fail on released v1.15.3 (the
:interp hops), so it still catches the bug.

Card: CLJW-WASM-INTERP-THREAD-TRAP
Smell-audited: 0: test moved from the bash tier to a suite, no source change
bench(wasm): pin core wasm/call against component-call; isolate interp cells
fix(edn): clojure.edn reads clj's EdnReader dialect, not the Clojure reader
… the module

zwasm resolved every by-name JIT invoke with findExportFunc, a section walk
plus an export-section decode, twice per facade call (zwasm/D-585, #208).
fix/jit-percall on the BuddhiLW/zwasm fork (22a99bb1, one commit on d74e63b4)
serves the name from CompiledWasm.exports instead.

bench/wasm_percall.clj gains big-auto / big-jit / big-interp: a trivial
export (popcount) of a 47 KB module, bench/wasm/percall/zigcool.wasm, so a
size-proportional cost is visible. Median ns per call, 7 x 20k calls,
ReleaseSafe -Dwasm, same host back to back (load average 11-15):

  cell              before  after
  JIT, 47 KB          5883    833
  JIT, add.wasm       1031    882
  interp, 47 KB        463    600   (noise; lookup unchanged)
  interp, add.wasm     496    649

The full bench still stops at interp-worker, which traps on the before and
after binaries alike (the interp thread-trap card, separate fix).

Card: CLJW-WASM-JIT-PERCALL-SCALES

Smell-audited: 1: dependency pin bump plus bench cells, no design change
…on line

The fork's main is now where cljw-found zwasm patches land (fork PR #2 merged
the interp per-thread stack limit), with each patch's evidence in the fork's
FORK.md and an upstream PR once a cljw release has carried it. 6d38dfcd has
the same tree as the fix commit 79f3d63a, so the .hash is unchanged
(zig fetch confirms zwasm-2.7.0-FT1Fv5zqngAGQ7CfqwShi1Dbt32Mn7jQ9RJ_qZZPdLol).

Card: CLJW-WASM-INTERP-THREAD-TRAP
Smell-audited: 0: dependency pin moved from a fix branch to the fork main, same tree
A cljw integer is an immediate only inside the i48 NaN-box window; past
it (about +/-2^47) it is a heap Long tagged .big_int. The core wasm
marshaller gated args on isNumber(), which sees only the immediate, so
wasm/call and the dot form refused 2^47..Long/MAX_VALUE as "argument is
not a number", and it lifted i64 results with Value.initInteger, which
demotes past the window to a lossy Double (2^53+1 lost precision, a
guest u64 with the top bit set came back as -9.223372036854776E18).

- marshal.coerceInt reads a heap Long / BigInt through big_int.toInt(T),
  so i64 args and WIT s64/u64 lowering take the full range; anything
  outside T is wasm_arg_out_of_range.
- marshal.fromWasm lifts i64 through promote.wrapI64 (the ffi :long
  idiom): exact Long, u64 bits wrap to the signed Long. callExport
  brackets multi-result lifting in a no-collect region, like mem-read.
- The f32/f64 arms (and mem-write! :f32/:f64) widen a heap Long/BigInt.
- An integral 2^63 float passed the `> maxInt(i64)` bound (maxInt rounds
  to 2^63 as an f64) into @intFromFloat, a ReleaseSafe host panic, in
  both wasm/call and mem-write!. The bound is now half-open at 2^63.

Test: suites.wasm-i64-range-test over a new identity fixture
(i64_range.wat), via wasm/call, the dot form, multi-result, mem :i64,
:u32/:i32 edges, refusals and the echo component's u64. Red on v1.15.3
(and the panic), green here.

Smell-audited: 1: local marshalling fix reusing the numeric tower's exact-i64 entries; no new seam
docs(examples): zig-native, one Zig source as a wasm guest and a cljw.ffi library
docs(works): collection performance, subvec fixed, numeric loops and the native route measured
CLJW-GC-HEAP-RETURN. Two defects made long-lived cljw processes only grow:

1. Return: swept cells went onto per-exact-size free pools that were never
   handed back to infra, so RSS was a high-water mark.
2. Reuse: the collect trigger counted only cell bytes. A transient vector's
   24-32 MB element buffer (and any String / array payload) on infra was
   invisible, so several bursts' buffers piled up as garbage before a
   collect; exact-size pools also kept every size class at its own peak.

Fix: page_heap.zig replaces free_pool.zig. Small cells (<= 4 KiB, align 8)
live in 64 KiB size-aligned pages with per-page free lists and live counts.
After every sweep, empty pages past a retention budget are unmapped: one
threshold's worth for an automatic collect (hysteresis), 1 MiB for
System/gc. Payload allocations are charged to the trigger through
GcHeap.noteOwnedAlloc / chargeInfraBytes (was checkInfraCap).
(.totalMemory r) now reports the committed cell heap (AD-063 updated).

Probe bench/gc_heap_return.clj, RSS MB, v1.15.3 -> this commit:
  start                        14 -> 14
  after 3M vector              44 -> 64
  after drop + System/gc       44 -> 17
  after 5 more 3M bursts      154 -> 65
  after 200 x 20k JSON + gc   149 -> 18

Smell-audited: 3: replaced the free-pool layer outright instead of bolting a trim onto it
bench/wasm_percall.clj: keep staging's one-instance-per-cell layout and its
core-f64x3 / component-f64 cells (#71), and add this branch's big-auto /
big-jit / big-interp cells on the 47 KB module, each with its own instance.

build.zig.zon: the fork's main now carries fork PR #2 (interp per-thread
stack limit), #3 (JIT by-name export lookup) and #4 (FORK.md); pin it at
e9d74934 instead of the fix branch. zig fetch: zwasm-2.7.0-FT1Fv3v5ngAavfQgV-JntjzckyYqMLUUCfYssfSrFN9S.

Card: CLJW-WASM-JIT-PERCALL-SCALES
Smell-audited: 0: merge resolution plus a dependency pin move to the fork main
fix(wasm): a handle called from a future no longer traps on the interp
#74 pinned the fork's main at 6d38dfcd (interp fix only); e9d74934 is that
plus the JIT export-lookup fix and FORK.md, so this branch's pin wins.
CHANGELOG keeps both Unreleased entries.

Smell-audited: 0: merge resolution, no source change
…, ZoneId/ZoneOffset

Recovered WIP after host reboot: JDK printer-parser model, ISO constants,
LocalDate/LocalTime/LocalDateTime/Instant parse/from/format via the engine.
compat_tiers rows + D-588 debt + java_time_format_test suite.
A Java array now carries its element kind. object arrays hold Values and
are traced; byte/short/int/long/float/double/char/boolean arrays hold raw
contiguous native elements (i8 .. f64), untraced. A primitive array is a
C array as it stands, so cljw.ffi :pointer passes its storage with no
copy, :bytes is one memcpy, and socket I/O, String. and
System/arraycopy are memcpy/memmove.

Constructors are native (__array-make / __array-from / __array-fill),
converting like clj's RT.longArray; aset is strict (clj's argument type
mismatch). bytes? is true only for a byte array, retiring AD-051; AD-019
narrows to make-array / into-array, which have no Class to read a kind
from.

Kanban 20261021235518-022419a9.

Smell-audited: 3: type erasure removed at the container, Values unchanged (F-004/F-005 hold); one store path with strict/convert modes instead of per-ctor coerce fns
A :pointer argument takes a primitive array and passes its storage; C
reads and writes it in place for the call. The ffi suite covers double,
int, float, long (C write-back) and byte arrays, :bytes copy semantics,
and the arg-type refusals. README gains the full types table and the
array-constructor to C element table.

Kanban 20261021235518-022419a9.
…report

Step 0 of decision 20261022012219-31249feb (kanban 20261022012233-420c9fea).
14 portable workloads, each >= 300 ms on bb, covering interpreter loops,
core seq/transducer/array work, collections, strings, doubles, GC and JSON.
run.sh reports startup, in-process and wall time separately, cljw/bb and
cljw/JVM ratios, optional perf instructions:u, and appends a YAML history
row. bench-vs-bb.yml runs it on ubuntu as a continue-on-error report, not a
gate tier. probe/ keeps the per-call and RSS probes from the baseline.
CLJW-GC-HEAP-RETURN follow-up. A BigInt's limbs live on infra behind a
32-byte cell, so a factorial loop's limb garbage was invisible to the
trigger the same way transient buffers were. wrapManaged now charges
ownedBytes (Managed header + limb capacity) through noteOwnedAlloc, and
allocFromManaged routes through wrapManaged so every BigInt cell passes
the one choke point.

Smell-audited: 1: one choke point instead of a charge at every arith site
… ceiling

Pure move, no behaviour change. gc_heap.zig 888 -> 388, mark_sweep.zig
830 -> 237 lines.

- gc_trigger.zig: the collect-trigger policy (threshold constants,
  pressure counters, maybeAutoCollect / requestCollect, retention budget,
  the post-sweep recompute and the time-share floor steering). GcHeap
  keeps the methods as decl aliases, so callers are unchanged.
- gc_types.zig: AllocRecord, PersistentMark, Stats, min_alloc_bytes and
  the header-at-offset-0 comptime check.
- conservative_scan.zig: the D-556 tree_walk native-stack scan.
- gc_heap_test.zig, mark_sweep_test.zig: the unit tests, registered in
  main.zig.

Smell-audited: 2: split by stratum (data shapes, policy, mechanism, tests) rather than by line count
wasm/mem-write! takes a primitive array as well as a vector: where the
array's kind is the dtype's layout (double-array and :f64, long-array
and :i64, ...) it is one memcpy, else element-wise with the vector
path's refusals. wasm/mem-read-array answers a primitive array, one
memcpy where the layout matches (:u16 / :u32 widen). New module
memory_array.zig keeps surface.zig from growing.

bench/ffi_prim_arrays.clj measures the 1000-element dot. ReleaseSafe
-Dwasm, loaded host, best of 5, us per call:
  v1.15.3   :bytes call only 19.6, pack + call 51,679, Clojure dot 2,174
  this      :pointer double-array call 1.16, build arrays + call 29.1,
            :bytes call only 1.73 (memcpy), pack + call 40,224

e2e phase16_wasm_memory covers every array kind (i8 i16 i32 i64 f32
f64), unsigned widening, a cross-kind convert and three refusals.

Kanban 20261021235518-022419a9.

Smell-audited: 2: bulk path split into its own module (surface.zig over the size ceiling); shared arg resolvers made pub rather than duplicated
… bb 1.13.224 vs clojure 1.12.4)

Geomean in-process cljw/bb 4.80, cljw/jvm 10.94 on a host at load 19.
Library-bound cells dominate: arrays 21.5x, lazy_chain 18.7x,
transduce 16.1x, reduce_map 15.2x, into_collections 12.4x; interpreter
cells 1.2-2.0x (protocol_dispatch 4.6x); json_roundtrip 1.12x.
BuddhiLW and others added 29 commits October 22, 2026 18:51
data/placement.yaml regenerated against the merged runtime (1461 vars).
Golden 36/36, phase15_watch_recursion legit_hof_recursion (500) passes,
smoke green. Depth 1000 (JVM parity) moves to CLJW-VM-CALL-PATH.

Smell-audited: 0: generated index only
.dev/optimizations.md resolved as the union of both sides (O-070..O-077
here, O-090 from #87).

Smell-audited: 0: ledger union only
.dev/optimizations.md and .dev/debt.yaml conflicted on every parallel PR
on 2026-10-22 (#85, #86 against #87), always as two sides appending rows.
git's union driver resolves exactly that; check_yaml and the ledger gates
still validate the result.

Smell-audited: 1: merge driver for a recurring mechanical conflict
…ngle-threaded wasm32

CI's zig build -Dtarget=wasm32-wasi failed: @atomicLoad/@AtomicStore on a
64-bit Value is not available on wasm32. The single-threaded targets
(ADR-0193) have no concurrent reader, so loadMethodVal/storeMethodVal use a
plain access there; 64-bit hosts keep the seqlock's atomics unchanged.
Verified: zig build -Dtarget=wasm32-wasi builds.

Smell-audited: 1: comptime single_threaded branch, the repo's existing idiom
Twelve test/e2e/phase15_java_time_*.sh scripts (one cljw process per block)
become suites.java-time-values-test: 11 deftests, 75 assertions, one process.
The ChronoUnit-mismatch error cases are catchable and are now
thrown-with-msg?. AD pins and compat/debt references repoint to the suite;
run_all rows removed; e2e ceiling 322 -> 310.

Smell-audited: 0: pure test-tier move, no runtime change
Fifteen scripts (alias, var_get_set, dynamic_var, with_local_vars, set_bang,
ns_binding_roundtrip, ns_runtime_fns, namespace, ns_docstring, use_refer_all,
use_libspec_filter, require_runtime_fn, ns_import, import_macro,
aliased_macro) become suites.vars-namespaces-test: 16 deftests, 97
assertions. Top-level-form cases run through load-string in a fresh
*ns*-bound scratch namespace, so ns switches and defs do not leak into the
suite. Error cases (non-dynamic binding, set! unbound/arity, missing lib,
unresolved import) are catchable and are now thrown-with-msg?. AD-010/
AD-015/AD-053 pins repoint to the suite. e2e ceiling 310 -> 295.

Smell-audited: 0: pure test-tier move, no runtime change
…ljw suite

Seventeen scripts (macro_literals, macro_heaplong, literal_roundtrip,
reader_conditional, unchecked_math, campaign_keywords, clojure_template,
clojure_uuid, clojure_instant, new_ctor, defmulti_defmethod,
defmethod_destructure, marker_protocol, defprotocol_docstring,
threaddeath_multifn, with_open, pr_newline) become
suites.forms-and-literals-test: 13 deftests, 103 assertions.
*unchecked-math* is a compile-time flag, so those cases bind it around a
load-string. Reader-conditional cases read with {:read-cond :allow}.

phase15_deferred_host_ref.sh keeps only raises_when_called: the call-time
raise is an uncatchable not_implemented. It is now marked
e2e-process-boundary; the AD-022 pin names both halves. e2e ceiling 295 -> 277.

Smell-audited: 0: pure test-tier move, no runtime change
…to a cljw suite

Eight scripts (clojure_test, clojure_test_output, clojure_test_tap,
test_junit, spec, repl_discovery, tap, clojure_stacktrace) become
suites.test-reporters-test: 8 deftests, 66 assertions. A program that
defines deftests and calls run-tests runs as a nested run with the
clojure.test dynamic state rebound and output captured. The custom-reporter
case binds the ^:dynamic report, so the shared multimethod stays untouched.
The tap cases filter on their own value because delivery is async. AD-029 and
AD-041 pins repoint to the suite. e2e ceiling 277 -> 269.

Smell-audited: 0: pure test-tier move, no runtime change
suites.concurrency-host-test (6 deftests, 68 assertions) takes every
in-process case from stm_dosync (retired), thread_lifecycle,
watch_recursion, system_property, require_fs and var_source_meta. The
watch/validator/direct-recursion overflow is a catchable StackOverflowError
and the thread-state errors are catchable, so all become thrown-with-msg?.

The five remaining scripts are reduced to their process cases and marked
e2e-process-boundary: join-at-exit/daemon exit, no-SIGSEGV exit codes,
child-environment getenv/TMPDIR, -cp/CLJW_PATH classpath resolution, and
the cljw build AOT artifact plus the cljw <file> entrypoint. STM and
var-meta pins repoint to the suite. e2e ceiling 269 -> 263; phase15 now has
zero counted scripts (6 exempt).

Smell-audited: 0: pure test-tier move, no runtime change
chore: ignore .scratch/, union-merge the append-only ledgers
The 8.5 MB burst landed in pages earlier suites had already committed on
#80's macOS gate (base 208 MB, grown +0.8 MB), so 'the burst grew the
committed heap' failed for reasons unrelated to the page heap. A 34 MB
burst with a 16 MB margin measures the page heap again. Suite smoke green
in the shared image (847 tests); 3/3 in a fresh process.
Card: CLJW-GC-HEAP-TEST-FRAGILE (20261022192250-76e31716).

Smell-audited: 1: test fragility fixed at its cause, knowledge 20261022192219-564f7893
perf: string and vector hot paths (CLJW-PERF-STRINGS-COLLS)
.dev/optimizations.md resolved as the union of both sides (this branch's
O-080/O-081 and #85's O-070..O-077); from now on .gitattributes merges it
by union.

Smell-audited: 0: ledger union only
data/placement.yaml regenerated against the merged runtime.

Smell-audited: 0: generated index only
A bb-mcp head on cljw only grew with traffic. Four causes, each
reproduced:

- The stdin readers (text_io *in*, host_stream System/in) appended every
  chunk to their buffer and only advanced the cursor, so every byte a
  server ever read stayed resident (400 MB through read-line: 470 MB
  RSS). stdin.dropConsumed compacts the consumed prefix before each fill
  (71 MB, flat).
- Nothing collected while the main thread blocked on stdin. Only the
  main thread may collect, and bb-mcp runs its calls on worker threads
  while main waits for the next request, so a burst of calls left all
  its garbage behind (20 calls of 4 MB JSON: 955 MB, held indefinitely).
  The stdin fill now waits with poll and, after each idle second, calls
  gc.collectBeforeIdle: a System/gc-style collect that keeps 1 MiB of
  empty pages, when at least 1 MiB was allocated since the last collect,
  followed by malloc_trim on glibc (129 MB, one second after the work
  ends).
- A cljw.net socket's 128 KiB buffers and a StringBuilder's growing
  buffer were invisible to the collect trigger. Both are now charged.
  The three copies of the socket construction become one wrapStream.

Six bursts of five such calls now hold at 72 MB, against 274 MB before.
perf(protocol): per-ProtocolFn dispatch cache + skip ReleaseSafe locals fill (ADR-0206)
test: migrate phase15 e2e scripts to cljw clojure.test suites
tree_walk.zig: #86 split callMethodImpl/callMethodBody with a 2 KB
uninitScratch locals buffer on the native stack; this branch moved the
window into the threadlocal tw_scratch for recursion depth. Kept one
mechanism: callMethodImpl is a plain call and callMethodBody borrows an
exact-size tw_scratch window, which neither 0xaa-fills nor costs native
stack. O-080 ledger row amended. data/placement.yaml regenerated.

Smell-audited: 2: duplicate per-call buffer removed at merge, ledger amended
The 8.5 MB burst landed in pages earlier suites had already committed on
#80's macOS gate (base 208 MB, grown +0.8 MB), so 'the burst grew the
committed heap' failed for reasons unrelated to the page heap. A 34 MB
burst with a 16 MB margin measures the page heap again. Suite smoke green
in the shared image (847 tests); 3/3 in a fresh process.
Card: CLJW-GC-HEAP-TEST-FRAGILE (20261022192250-76e31716).

Smell-audited: 1: test fragility fixed at its cause, knowledge 20261022192219-564f7893
fix(gc): return memory in long-running stdin servers (bb-mcp)
… newest tag

clojurewasm/ClojureWasm main CI failed on the PR #19 merge (runs
39701804074, 39701800082): the mirror has no v1.15.2 tag (its release job
died on a homebrew-tap 403 before tagging), and the CI run raced the release
workflow that tags v1.15.3 on that same merge. The newest reachable tag was
v1.15.1, the script skipped only the pending [1.15.3] heading, then refused
[1.15.2].

Every heading between [Unreleased] and the newest tag's heading is now
checked on its own: dated, newer than the newest tag, no newer than
build.zig.zon's version, and untagged. A stray or out-of-range heading still
fails, and so does a missing heading for the newest tag.
Added, Changed and Fixed bullets for #73, #77 to #79, #81 to #87 and this
PR (ADR-0205), which merged with no CHANGELOG entry.
fix(gate): changelog_reachability accepts untagged releases above the newest tag
perf(core): hot core library through one chunked-seq boundary and the Zig reduce (ADR-0205)
Release: memory return for long-running servers, interpreter hot paths
@BuddhiLW
BuddhiLW merged commit 65e5c0e into clojurewasm:main Oct 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant