Skip to content

Commit ba406d0

Browse files
committed
chore(release): v1.9.0 — changelog, version bump, and CI-parity guard
CHANGELOG carries the release; build.zig.zon moves to 1.9.0. Rides with it, found by checking CI before tagging: budget_thread_ownership.sh used a bare `timeout` and python3 clock, and the CI macOS runner ships neither GNU timeout nor gtimeout — exit 127, the step failed at 0s. Locally green, red on push, the SECOND time this exact class has bitten (check_doc_coverage.sh, 2026-08-04), both times with the in-repo `run_bounded` fix sitting unused. So the fix is mechanical this time: scripts/check_portable_timeout.sh fails the gate (smoke included) on any bare `timeout` in a gate-reachable script — every test/e2e/*.sh plus every scripts/*.sh named by run_all.sh — with the clj-requiring dev tools exempt by explicit list. The e2e itself moves to run_bounded + bash SECONDS. Local-vs-CI parity is a check, not a memory. run_gate's default timeout rises 600 -> 900 s: a cold-cache serial full gate measured over 600 (the two zig build test steps alone were 271 s), and a timeout that kills a healthy gate teaches the wrong lesson. Warm gate: 283 s. Pre-tag cadence run: full gate 429/0, lib_conformance --all regenerated COVERAGE.md, verify_projects 19/19. Smell-audited: 2: the user asked whether CI was actually green — it was not, and the divergence class already had a precedent and an unused in-repo fix. The guard makes the class mechanical rather than remembered, per the same posture as check_epipe_head / gate_parity.
1 parent 3e78636 commit ba406d0

7 files changed

Lines changed: 152 additions & 26 deletions

File tree

‎.dev/handover.md‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
`1ecc7a8a` (zwasm main; carries #157 component export index space + #158/#159
1414
the WASI capture cap — both load-bearing for cljw, unlike the v2.4.0 tag it
1515
replaced. Tagging zwasm is user-only, its ADR-0156). Latest release:
16-
**v1.8.0** (2026-08-04; multi-export components load, `wasm/run` gains
17-
output + wall-clock bounds, `clojure.core.reducers` lands, and `(doc reduce)`
18-
finally prints something). CHANGELOG is the release-history SSOT.
19-
- **First task on resume**: the remaining distribution repos —
20-
`cw-playground`, `cw-serverless-demo`, `cw-arcade`. The homebrew tap is DONE
21-
(cljw 1.8.0 + zwasm 2.4.1, `brew audit` clean after dropping the redundant
22-
explicit `version` — Homebrew scans it from the release URL).
16+
**v1.9.0** (2026-08-05; the eval budget bounds blocking calls and follows
17+
spawned work, data errors are catchable, regex gains named groups /
18+
lookbehind / `\A\z\Z` / class algebra, multidim aget/aset, and the GC
19+
stops taxing long-running programs 2-4x — D-571/573/446/447 discharged).
20+
CHANGELOG is the release-history SSOT.
21+
- **First task on resume**: re-pin the demo repos (`cw-playground`,
22+
`cw-serverless-demo`) to v1.9.0 via their `check_version_pins.sh
23+
--set-cljw` and verify the fly deploys — the playground's malformed-JSON
24+
400 handler only takes effect with the new pin. Homebrew tap: bump to
25+
1.9.0 (same flow as 1.8.0, which is DONE and `brew audit` clean).
2326
Release-process note worth keeping: **cutting the GitHub release by hand
2427
before the tag workflow runs breaks the artifact upload**. zwasm's workflow
2528
did an unconditional `gh release create`, which failed on "already exists"

‎CHANGELOG.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,67 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces.
77

88
## [Unreleased]
99

10+
## [1.9.0] - 2026-08-05
11+
12+
**Upgrade if you run untrusted code under `cljw.eval/with-budget`, parse
13+
untrusted JSON/EDN, use long-running programs (the GC got a lot faster on
14+
them), or want named groups / lookbehind / `\A \z \Z` / class algebra in
15+
regexes.** MINOR rather than PATCH because behaviour changes: code that
16+
relied on a blocking call outliving its budget now raises.
17+
18+
### Changed
19+
20+
- **The eval budget's wall-clock deadline now bounds blocking calls — all of
21+
them.** `(Thread/sleep 25000)` under a 3 s deadline used to return normally
22+
after 25 s; `@(promise)` under a deadline used to park forever. Every
23+
blocking primitive (`future`/`promise` deref, timed deref, `Thread/sleep`,
24+
`await`) now waits on a latch bounded by `min(caller timeout, budget
25+
deadline)`.
26+
- **The budget follows the work, not the thread.** A `Thread.`, `future` or
27+
agent action spawned inside a `with-budget` extent inherits the budget —
28+
same absolute deadline, same shared (atomic) step ceiling — and keeps it
29+
even if it outlives the extent. A spawned 60 s sleep under a 500 ms
30+
deadline used to hold the process for the full 60 s; it now dies at the
31+
deadline. A compute loop cannot escape the step ceiling by moving onto a
32+
spawned thread.
33+
- **A rejection caused by bad DATA is now catchable.** Malformed JSON/EDN
34+
input, wrong-typed arguments (`clojure.string/replace`, `deliver`, walk
35+
callbacks, `*out*` bindings), malformed form shapes under `eval` /
36+
`read-string` (libspecs, destructuring, `ns` directives, reader
37+
conditionals) and malformed regex patterns all raise catchable errors, as
38+
JVM Clojure does — ~60 sites reclassified, each verified against the
39+
oracle. Genuinely unsupported features stay deliberately uncatchable.
40+
Messages improve with it: "deliver: expected a promise, got integer"
41+
instead of "… is not supported in ClojureWasm".
42+
43+
### Added
44+
45+
- **Regex: named groups** `(?<name>e)` (with `(.group m "name")`),
46+
**lookbehind** `(?<=)` / `(?<!)`, **`\A` `\z` `\Z` anchors**, **nested
47+
character-class unions** `[a[b]]` and **`&&` intersection**
48+
(`[a-z&&[^m-p]]`), and empty alternatives/groups (`()`, `a|`).
49+
Backreferences are declined by design — the matcher guarantees linear-time
50+
matching (the RE2 posture), and a backreference would force backtracking.
51+
- **Multi-dimensional `aget` / `aset`** and the 8 typed `aset-*` multidim
52+
arms: `(aget a i j)`, `(aset a i j v)` on nested arrays, exactly clj's
53+
variadic recursion.
54+
55+
### Fixed
56+
57+
- **Long-running programs stopped paying a 2-4x GC tax.** The collection
58+
trigger is steered by the measured GC time share (the same control variable
59+
SubstrateVM sizes its heap with), and live-set accounting now counts what
60+
an object owns (string payloads, array elements, bignum limbs) instead of
61+
only its 32-byte record. A breadth-first search went 58.9 s → 17.7 s
62+
(1624 → 114 collections); a 200k-string workload went 263 → 55 collections.
63+
Short scripts and benchmarks are unchanged.
64+
- **`@(future (Thread/sleep 2000))` ran 17% long** (2321-2358 ms) with no
65+
budget armed at all — the cancel poll sliced every worker sleep. Waits are
66+
latch-based now: 2000-2001 ms, and `future-cancel` wakes a sleeping worker
67+
in 0 ms instead of up to 20 ms.
68+
- **`(Thread/sleep <huge long>)` aborted the process** with an integer
69+
overflow; it now sleeps, as clj does.
70+
1071
## [1.8.0] - 2026-08-04
1172

1273
**Upgrade if you use Wasm components, or if you use `doc` / `find-doc`.**

‎build.zig.zon‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .cljw,
3-
.version = "1.8.0",
3+
.version = "1.9.0",
44
.fingerprint = 0x1869d207073beffa,
55
.minimum_zig_version = "0.16.0",
66
.dependencies = .{

‎scripts/check_portable_timeout.sh‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# check_portable_timeout.sh — no bare `timeout` in anything the gate runs.
3+
#
4+
# The CI macOS runner ships neither GNU `timeout` nor coreutils `gtimeout`:
5+
# a bare `timeout 30 cmd` exits 127 there, so the step fails at 0s — locally
6+
# green, red on every push. This class has now bitten twice:
7+
# 2026-08-04 scripts/check_doc_coverage.sh (fixed with run_bounded)
8+
# 2026-08-05 test/e2e/budget_thread_ownership.sh
9+
# Both times the fix already existed in-repo (`run_bounded`: timeout, else
10+
# gtimeout, else unbounded) and was not used because nothing enforced it.
11+
# This check is the enforcement: local-vs-CI parity must be mechanical, not
12+
# a thing each author remembers (same posture as check_epipe_head.sh and
13+
# gate_parity for their classes).
14+
#
15+
# Scope = what the gate executes: every test/e2e/*.sh, plus every
16+
# scripts/*.sh referenced by test/run_all.sh. Dev-machine tools that REQUIRE
17+
# a JVM clj (clj_diff_sweep, lib_conformance, extract_core_meta,
18+
# verify_projects) never run on CI and are exempt — listed explicitly so a
19+
# new gate reference to one of them trips this check and forces the
20+
# decision.
21+
set -euo pipefail
22+
cd "$(dirname "$0")/.."
23+
24+
EXEMPT='clj_diff_sweep.sh|lib_conformance.sh|extract_core_meta.sh|verify_projects.sh|run_remote_ubuntu.sh|run_gate.sh'
25+
26+
# Gate-reachable scripts: e2e + scripts named in run_all.sh.
27+
files=(test/e2e/*.sh)
28+
while IFS= read -r f; do
29+
[[ -f "$f" ]] && files+=("$f")
30+
done < <(grep -oE 'scripts/[a-z_0-9]+\.sh' test/run_all.sh | sort -u)
31+
32+
fail=0
33+
for f in "${files[@]}"; do
34+
base=$(basename "$f")
35+
[[ "$base" =~ ^($EXEMPT)$ ]] && continue
36+
# A bare invocation is `timeout <digits>` at a command position, outside
37+
# the `command -v timeout` feature-probe line of a run_bounded helper.
38+
hits=$(grep -nE '(^|[ \t(;&|])g?timeout +[0-9]' "$f" | grep -v 'command -v' || true)
39+
if [[ -n "$hits" ]]; then
40+
echo "check_portable_timeout: bare timeout in $f (CI macOS has none — use a run_bounded helper):" >&2
41+
sed 's/^/ /' <<< "$hits" >&2
42+
fail=1
43+
fi
44+
done
45+
46+
if [[ "$fail" -eq 0 ]]; then
47+
echo "check_portable_timeout: ok — no bare timeout in gate-reachable scripts"
48+
fi
49+
exit "$fail"

‎scripts/run_gate.sh‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# 2. Reap `cljw` probes orphaned to PID 1 (re-parented when their gate
1919
# died) — precise: ppid==1 only, so a live gate's children and any
2020
# legitimate interactive `cljw` are untouched.
21-
# 3. Run exactly ONE gate under a bounded timeout (default 300 s —
21+
# 3. Run exactly ONE gate under a bounded timeout (default 900 s —
2222
# generous for a ~190 s parallel / ~350 s serial gate once nothing
2323
# contends; a gate that exceeds it is stuck, not slow. Raised to 600 with
2424
# --serial-e2e now the default here: the serial path is ~2x the parallel
@@ -38,7 +38,7 @@ set -uo pipefail
3838
cd "$(dirname "$0")/.."
3939

4040
SELF=$$
41-
TIMEOUT="${GATE_TIMEOUT:-600}"
41+
TIMEOUT="${GATE_TIMEOUT:-900}"
4242

4343
reap_gates() {
4444
local pid ppid n

‎test/e2e/budget_thread_ownership.sh‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,44 +26,56 @@ BIN="zig-out/bin/cljw"
2626
[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
2727

2828
fail() { echo "FAIL $1" >&2; exit 1; }
29-
now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
29+
30+
# Portable bounded run: GNU `timeout`, else macOS coreutils `gtimeout`, else
31+
# unbounded (the CI macOS runner ships NEITHER — a bare `timeout` exits 127
32+
# there and fails the step at 0s, which is exactly how this file broke CI on
33+
# 2026-08-05; scripts/check_portable_timeout.sh now guards the class). Every
34+
# case's expressions are deadline-bounded by the feature under test, so the
35+
# unbounded fallback cannot hang past the budget it asserts.
36+
run_bounded() {
37+
local secs="$1"; shift
38+
if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@"
39+
elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@"
40+
else "$@"; fi
41+
}
3042

3143
# --- (a) a sleeping Thread dies at the deadline; the process is not held ---
3244
# Before: the process hung for the worker's full 60 s sleep (the join-at-exit
3345
# barrier waited on an unmetered non-daemon thread).
34-
t0=$(now_ms)
35-
got=$(timeout 30 "$BIN" - <<'EOF' 2>/dev/null
46+
SECONDS=0
47+
got=$(run_bounded 30 "$BIN" - <<'EOF' 2>/dev/null
3648
(println (cljw.eval/with-budget {:deadline-ms 500}
3749
(fn [] (.start (Thread. (fn [] (Thread/sleep 60000)))) :spawned)))
3850
EOF
3951
) || fail "thread_sleep_dies_at_deadline: non-zero exit"
40-
elapsed=$(( $(now_ms) - t0 ))
52+
elapsed=$SECONDS
4153
grep -qx ':spawned' <<< "$got" || fail "thread_sleep_dies_at_deadline: got '$got'"
4254
# Order-of-magnitude bound (test_taxonomy.md): catches "waited out the 60 s
43-
# sleep", not scheduling noise.
44-
[ "$elapsed" -lt 15000 ] || fail "thread_sleep_dies_at_deadline: process held ${elapsed}ms (worker not bounded)"
45-
echo "PASS thread_sleep_dies_at_deadline (${elapsed}ms)"
55+
# sleep", not scheduling noise. Bash SECONDS — no external clock needed.
56+
[ "$elapsed" -lt 15 ] || fail "thread_sleep_dies_at_deadline: process held ${elapsed}s (worker not bounded)"
57+
echo "PASS thread_sleep_dies_at_deadline (${elapsed}s)"
4658

4759
# --- (b) a compute-loop Thread trips the SHARED step ceiling and dies ---
4860
# The ceiling is high enough that the worker cannot trip before the extent
4961
# exits — before the fix it then span forever unmetered and the process hung.
50-
t0=$(now_ms)
51-
got=$(timeout 60 "$BIN" - <<'EOF' 2>&1
62+
SECONDS=0
63+
got=$(run_bounded 60 "$BIN" - <<'EOF' 2>&1
5264
(println (cljw.eval/with-budget {:max-steps 30000000}
5365
(fn [] (.start (Thread. (fn [] (loop [i 0] (recur (inc i)))))) :done)))
5466
EOF
5567
) || fail "thread_loop_trips_shared_steps: non-zero exit"
56-
elapsed=$(( $(now_ms) - t0 ))
68+
elapsed=$SECONDS
5769
grep -qx ':done' <<< "$got" || fail "thread_loop_trips_shared_steps: got '$got'"
5870
grep -q 'step budget' <<< "$got" || fail "thread_loop_trips_shared_steps: worker did not trip the shared ceiling: '$got'"
59-
[ "$elapsed" -lt 45000 ] || fail "thread_loop_trips_shared_steps: process held ${elapsed}ms"
60-
echo "PASS thread_loop_trips_shared_steps (${elapsed}ms)"
71+
[ "$elapsed" -lt 45 ] || fail "thread_loop_trips_shared_steps: process held ${elapsed}s"
72+
echo "PASS thread_loop_trips_shared_steps (${elapsed}s)"
6173

6274
# --- (c) a future spawned in the extent dies at the deadline ---
6375
# The process does not wait on future workers at exit, so assert in-process:
6476
# 1.5 s after a 400 ms deadline the future must be realised (dead), not still
6577
# sleeping out its 60 s. This is the long-lived-server case (the playground).
66-
got=$(timeout 30 "$BIN" - <<'EOF' 2>/dev/null
78+
got=$(run_bounded 30 "$BIN" - <<'EOF' 2>/dev/null
6779
(def f (atom nil))
6880
(cljw.eval/with-budget {:deadline-ms 400}
6981
(fn [] (reset! f (future (Thread/sleep 60000))) :spawned))
@@ -77,7 +89,7 @@ echo "PASS future_dies_at_deadline"
7789
# --- (d) an agent action sent in the extent dies at the deadline ---
7890
# The action sleeps 60 s; 1.5 s after the 400 ms deadline the agent must be
7991
# failed (the budget raise is the action's error), not still parked.
80-
got=$(timeout 30 "$BIN" - <<'EOF' 2>/dev/null
92+
got=$(run_bounded 30 "$BIN" - <<'EOF' 2>/dev/null
8193
(def a (agent 0))
8294
(cljw.eval/with-budget {:deadline-ms 400}
8395
(fn [] (send-off a (fn [_] (Thread/sleep 60000) 1)) :sent))
@@ -89,7 +101,7 @@ grep -qx '\[:failed true\]' <<< "$got" || fail "agent_action_dies_at_deadline: g
89101
echo "PASS agent_action_dies_at_deadline"
90102

91103
# --- (e) no budget => spawned threads stay unmetered (the default is unchanged) ---
92-
got=$(timeout 30 "$BIN" - <<'EOF' 2>/dev/null
104+
got=$(run_bounded 30 "$BIN" - <<'EOF' 2>/dev/null
93105
(def done (promise))
94106
(.start (Thread. (fn [] (Thread/sleep 200) (deliver done :ok))))
95107
(prn [:joined @done])
@@ -101,7 +113,7 @@ echo "PASS no_budget_unmetered"
101113
# --- (f) after the extent exits cleanly, NEW spawns are not metered by it ---
102114
# The budget follows work spawned INSIDE the extent; work spawned after must
103115
# not inherit a stale reference.
104-
got=$(timeout 30 "$BIN" - <<'EOF' 2>/dev/null
116+
got=$(run_bounded 30 "$BIN" - <<'EOF' 2>/dev/null
105117
(cljw.eval/with-budget {:deadline-ms 300} (fn [] :done))
106118
(Thread/sleep 400)
107119
(def done (promise))

‎test/run_all.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ run_step "doc_coverage" "bash scripts/check_doc_coverage.sh"
415415
# producer takes SIGPIPE, the script dies before printing its diagnostic, and it
416416
# only bites on a loaded machine. ab84126b fixed four sites and left 24.
417417
run_step "epipe_head" "bash scripts/check_epipe_head.sh"
418+
run_step "portable_timeout" "bash scripts/check_portable_timeout.sh"
418419
# The local full gate and CI's must be the SAME run. ci_gate.sh claimed they
419420
# were; nothing checked, and they were not (parallel vs serial e2e).
420421
run_step "gate_parity" "bash scripts/check_gate_parity.sh"

0 commit comments

Comments
 (0)