Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

Commit 10997b6

Browse files
committed
0027: a Clojure throw is a Wasm exception, and S3's last open decision closes
One nominal tag, clj-exn, whose payload is a Clojure value — today (struct $Exn (field $class i32)), the class enum living as :exn-class rows in the same trap table, the message half waiting for strings the way printing waits in 0022 B.3. The overflow arms of add, sub and mul stop being unreachable and throw class 1; the corpus compares all three against real clojure's ArithmeticException "long overflow". Traps stay traps because they must: the review verified catch_all passes native traps AND stack exhaustion through on both engines — so throws-as-traps would make try a whole-program rewrite, and one divergence is numbered before try exists (the JVM catches StackOverflowError; wasm never will). Comparison precision is two-laned and the hole is in the artifact: the V8 runner reads the thrown payload's class back through an exported helper (GC structs are opaque to JS; tag identity is per-instance), while wasmtime prints "thrown Wasm exception" for every class — row 3 carries that duplication visibly. The tested two-param-tag alternative is rejected in the note for the reason that decides it: the tag signature is the forever cross-module ABI, the helper is disposable scaffolding. Grammar changed, so 0026's interim rule ran: 119/119 modules agree between wasm-tools and binaryen.js. Every S3 design decision is now closed; the stop condition's missing half is a real browser page. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 7b03674 commit 10997b6

7 files changed

Lines changed: 217 additions & 17 deletions

File tree

‎corpus/run.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ const instance = new WebAssembly.Instance(
1414
try {
1515
console.log(`result ${instance.exports.entry()}`);
1616
} catch (e) {
17+
// An uncaught Clojure throw is a WebAssembly.Exception on our tag;
18+
// its payload's class is read back through Wasm, since GC structs
19+
// are opaque to JS (0027). The tag must be THIS instance's — tag
20+
// identity is per-instance.
21+
if (e instanceof WebAssembly.Exception && e.is(instance.exports["clj-exn"])) {
22+
console.log(`exn ${instance.exports.exn_class(e.getArg(instance.exports["clj-exn"], 0))}`);
23+
}
1724
// V8 reports wasm stack exhaustion as RangeError, not RuntimeError
1825
// (0024) — both are classified outcomes, not harness failures.
19-
if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
26+
else if (e instanceof WebAssembly.RuntimeError || e instanceof RangeError)
2027
console.log(`trap ${e.message}`);
2128
else throw e;
2229
}

‎corpus/s3.edn‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,9 @@
121121
{:id "box-fib-91" :forms [(defn fib-iter [n]
122122
(loop [i n a 0 b 1]
123123
(if (< 0 i) (recur (- i 1) b (+ a b)) a)))
124-
(fib-iter 91)]}]
124+
(fib-iter 91)]}
125+
;; --- a taken overflow arm throws (0027, trap-table row 3): all three
126+
;; ops, each against real clojure's ArithmeticException "long overflow".
127+
{:id "throw-add-overflow" :forms [(+ 9223372036854775807 1)]}
128+
{:id "throw-neg-min" :forms [(- -9223372036854775808)]}
129+
{:id "throw-mul-overflow" :forms [(* 4611686018427387904 2)]}]

‎corpus/trap_table.edn‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
;; 2026-07-30 on the pinned toolchain (wasmtime 47.0.1, V8/node).
1111
;; Row 2 matches on class alone (:jvm-message "") — StackOverflowError's
1212
;; message is null, a recorded weakening of the jvm-message guard (0024).
13+
;;
14+
;; Rows with :exn-class are thrown Wasm exceptions (0027): the V8 lane
15+
;; reads the class enum precisely; the wasmtime CLI lane prints the
16+
;; same "thrown Wasm exception" for every class — the visible
17+
;; duplication IS the named hole, retired when try/catch makes classes
18+
;; observable in-program.
1319
[{:row 1
1420
:class "java.lang.ArithmeticException"
1521
:jvm-message "/ by zero"
@@ -19,4 +25,9 @@
1925
:class "java.lang.StackOverflowError"
2026
:jvm-message ""
2127
:wasmtime "call stack exhausted"
22-
:v8 "Maximum call stack size exceeded"}]
28+
:v8 "Maximum call stack size exceeded"}
29+
{:row 3
30+
:class "java.lang.ArithmeticException"
31+
:jvm-message "long overflow"
32+
:exn-class 1
33+
:wasmtime "thrown Wasm exception"}]
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# 0027 — The throw representation: one tag, a class payload, two lanes of precision
2+
3+
**Status:** proposed · 2026-07-30 · adversarially reviewed the same day,
4+
before implementation. The review re-verified every engine claim
5+
(traps and stack exhaustion escape `catch_all` on both engines — the
6+
central argument holds) and changed five things, marked *(review)*:
7+
the tested two-param-tag alternative with the real reason it loses,
8+
the backtrace evidence the class-blind lane actually has, a divergence
9+
numbered before it exists (`try` will not catch stack exhaustion),
10+
the per-instance tag identity warning, and how a blind row reads in
11+
the table.
12+
13+
## The question
14+
15+
`0022` C deferred the throw representation to its own note — an
16+
output-format decision in `0009`'s non-retrofittable sense — and `0025`
17+
bracketed its cost: every boxed op already pays the overflow *check*,
18+
and the *taken* arm is still `(unreachable)`, so `(+ Long/MAX 1)` kills
19+
the program with the wrong identity instead of throwing what `clojure`
20+
throws. What is a thrown Clojure exception in emitted Wasm, how does an
21+
uncaught one compare against the oracle, and what waits for `try`?
22+
23+
## The decision
24+
25+
1. **A Clojure throw is a Wasm exception** — the final exception-handling
26+
proposal: `tag`, `throw`, `try_table`. Verified by running on every
27+
leg of the pinned toolchain (2026-07-30): `wasm-tools` parses and
28+
validates; binaryen.js 131 assembles it (the `0026` differential
29+
obligation, checked before deciding); V8/node catches in-module and
30+
surfaces uncaught ones as `WebAssembly.Exception`; wasmtime 47.0.1
31+
needs **no flag**, catches in-module, and reports an uncaught one as
32+
"thrown Wasm exception", exit 1. The alternative — representing
33+
throws as traps — is not a simplification but a dead end: **a trap
34+
cannot be caught by `try_table`**, so the day `try` lands every
35+
throw site would need re-emitting; that is the non-retrofittable
36+
half, decided now.
37+
2. **One tag, `$clj-exn`, with a `(ref null eq)` payload.** The payload
38+
is an ordinary Clojure value — which is what `ex-info` needs later —
39+
and today it is `(struct $Exn (field $class i32))`: a class enum,
40+
because no string representation exists yet, so the *message* half of
41+
an exception waits for strings exactly as printing waits in
42+
`0022` B.3. The enum is not a new artifact: trap-table rows gain an
43+
`:exn-class` column, keeping one numbered table for every
44+
error-comparison fact.
45+
3. **Tags are nominal, and the cross-module story is named, not built:**
46+
two modules share `$clj-exn` only through import/export — unlike rec
47+
groups, there is no structural identity for tags — so dev-mode
48+
cross-form catching lands with the shared-runtime import shape
49+
(`0026`'s preamble exit, `0009`'s world). Today's corpus modules are
50+
self-contained, so a per-module tag is correct, not a compromise.
51+
One measured warning for any future runner *(review)*: tag identity
52+
is per-instance — `e.is()` against a byte-identical second
53+
instance's tag is false — so a runner in the shared-runtime world
54+
must hold the tag of the instance it observes, never a cached one.
55+
4. **Two lanes of comparison precision, the hole named** (`0022` B.4):
56+
the V8 lane is **class-precise** — the module exports the tag and an
57+
`exn_class` helper, and the runner reads the payload's class back
58+
*through* Wasm, since GC structs are opaque to JS. The wasmtime CLI
59+
lane is **class-blind on exceptions** — every uncaught throw prints
60+
"thrown Wasm exception", so a blind row carries the same string as
61+
every other exception row, and that visible duplication inside the
62+
table is the honest rendering of the hole *(review)* — recorded
63+
rather than discovered later; the V8 lane carries class precision
64+
until `try`/`catch` makes classes observable in-program, which
65+
retires the hole. The lane is less blind than the string alone
66+
*(review)*: the backtrace names the throwing function (`!add`), and
67+
the hole is accepted **with that evidence left unparsed** — frame
68+
names are a debug section, not a contract, and would vanish under
69+
inlining or a names strip. Native traps (division, cast,
70+
`unreachable`, exhaustion) are unchanged rows — and one divergence
71+
is numbered before it exists *(review)*: **`try` will not catch
72+
stack exhaustion** — `catch_all` provably passes it through on both
73+
engines — where the JVM's `(catch StackOverflowError …)` works.
74+
Row 2's trap stays a trap even under `try`; number it the day `try`
75+
lands.
76+
5. **The overflow arms throw, this commit:** `$add`/`$sub`/`$mul`'s
77+
taken arms become `throw $clj-exn` with class 1
78+
(`java.lang.ArithmeticException`, jvm-message "long overflow"), and
79+
`(+ 9223372036854775807 1)` finally enters the corpus — with its
80+
entries in the same commit, per `0022` B.1.
81+
82+
## Why
83+
84+
- Every claim above about engine behaviour was produced by running the
85+
probe this session (scratchpad `eh_probe.wat`): catch-in-module
86+
returns the payload's class on both engines; uncaught behaviour and
87+
its exact strings are as quoted; binaryen.js round-trips it.
88+
- The single-tag shape mirrors the JVM itself: one `athrow`, class as
89+
data. It also keeps `try` emission single-handler when it lands.
90+
- Deciding *now* costs one struct type and one tag in the runtime;
91+
deciding *later* costs re-emitting every overflow arm and every
92+
future throw site under whatever `try` needs.
93+
94+
## Alternatives rejected
95+
96+
- **Traps for Clojure throws.** Uncatchable by `try_table`; would make
97+
`try` a whole-program rewrite. The reason this note exists.
98+
- **A (kind, payload) multi-value entry protocol** to give the wasmtime
99+
CLI lane class precision on uncaught exceptions. Machinery for a
100+
failure class nobody has seen, priced at touching every entry in both
101+
runners; superseded anyway when `try` lands and classes become
102+
observable in-program. The named hole plus V8-lane precision covers
103+
the interim.
104+
- **The class as a second tag parameter** — `(tag (param i32
105+
(ref null eq)))`, the class directly readable by the runner via
106+
`getArg`, no helper export. **Tested, and it works** *(review)*; it
107+
loses anyway, for the reason that actually decides this note: the
108+
tag signature is the durable cross-module ABI (§3 — nominal,
109+
import/export, forever), while the `exn_class` helper is disposable
110+
corpus scaffolding. Baking a harness convenience into the permanent
111+
ABI is the wrong trade, and the JVM analogy stays exact: one
112+
`athrow`, one operand.
113+
- **One tag per exception class.** Tag explosion, cross-module identity
114+
per class, and `try_table` needing a handler row per class — for
115+
information that is data, not control flow.
116+
- **Reading the payload from JS via reflection.** GC structs are opaque
117+
to JS by design; the helper-through-wasm is the supported path, not a
118+
workaround.
119+
120+
## What would falsify this
121+
122+
- **The cost of a taken throw is unmeasured** — only the untaken check
123+
is priced (B8). The throw/catch benchmark belongs before the `try`
124+
emitter work, where catch frequency becomes a real workload; a cold
125+
throw on an error path has no budget to violate.
126+
- **An engine changing its default for exception handling** — the
127+
pinned versions carry the evidence; re-verify on any toolchain bump
128+
(`/survey`'s three-month rule).
129+
- **The class enum outgrowing the table before strings land** — if the
130+
enum needs more than the JVM classes the corpus actually compares,
131+
that is the signal to pull strings forward rather than grow the enum.

‎doc/status.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,18 @@ _Short by design, and printed at every session start — so findings live in
5757
flags (never `Features.All`), non-stacky emission.
5858
`corpus/devloop_differential.mjs` (113/113) is part of "done" for
5959
emitted-grammar changes until the dev lane is in the gate.
60-
Remaining open: **the throw representation** — the next unit —
61-
after which S2's nREPL unit is unblocked on both sides.
60+
**The throw representation is decided and implemented** (`0027`):
61+
a Clojure throw is a Wasm exception — one nominal tag, a class-enum
62+
payload (`$Exn`; message waits for strings), overflow arms throw
63+
`ArithmeticException`/"long overflow" (row 3, all three ops in the
64+
corpus), the V8 lane class-precise, the wasmtime CLI lane
65+
class-blind by a named hole, and traps stay uncatchable — including
66+
stack exhaustion, a divergence numbered before `try` exists. **Every
67+
S3 design decision is now closed.** Next: the S3 stop condition
68+
still says *a browser*, and only node's V8 has run anything — the
69+
next unit is **a real browser page running a corpus module**, the
70+
smallest thing that would fail if "browser" were untrue; then S2's
71+
nREPL unit, now unblocked on both sides.
6272

6373
Done since the last update: `0016` `own<T>` handles, `0017` host imports
6474
(A–F), `0018` host-defined resources, `0012`'s `ex-data` contract shrunk to

‎src/cljwit/emit.clj‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
are `$BoxI64` structs; nil is the null reference; false and true are
88
singleton struct globals distinct by identity. Arithmetic unboxes
99
either representation, computes in i64, and re-boxes through the one
10-
canonicalizing guard. The overflow arms are `(unreachable)` — the
11-
throw representation is the open decision (`0022` C, `0025` §4) and
12-
no corpus entry may reach them.
10+
canonicalizing guard; a taken overflow arm throws (`0027`).
1311
1412
fn (`0024`): closures are subtypes of a `$Fn` base struct carrying one
1513
nullable typed-function-ref slot per arity; each capture signature is
@@ -18,7 +16,12 @@
1816
which are plain `call` — the first place the two modes emit different
1917
bytes. Method funcs declare `(type $sigN)` explicitly: a bare func
2018
type would canonicalize as its own singleton rec group and lose
21-
identity with the slot's field type (`0009`'s rule, again)."
19+
identity with the slot's field type (`0009`'s rule, again).
20+
21+
throw (`0027`): a Clojure throw is a Wasm exception — one tag
22+
`$clj-exn` whose payload is a Clojure value, today `$Exn` with a
23+
class enum (trap_table.edn's :exn-class column). The overflow arms
24+
throw class 1; message comparison waits for strings."
2225
(:require [clojure.string :as str]
2326
[clojure.tools.analyzer.ast :as ast]))
2427

@@ -377,6 +380,15 @@
377380
(def ^:private runtime
378381
" (type $Unit (struct))
379382
(type $BoxI64 (struct (field $v i64)))
383+
;; The throw representation (0027): one tag, payload is a Clojure
384+
;; value — today a class enum; the message half waits for strings.
385+
(type $Exn (struct (field $class i32)))
386+
(tag $clj-exn (export \"clj-exn\") (param (ref null eq)))
387+
;; Corpus scaffolding, not ABI: lets the V8 runner read a caught
388+
;; payload's class back through Wasm, since GC structs are opaque
389+
;; to JS (0027).
390+
(func $exn-class (export \"exn_class\") (param $e (ref null eq)) (result i64)
391+
(i64.extend_i32_s (struct.get $Exn $class (ref.cast (ref $Exn) (local.get $e)))))
380392
(global $false (ref $Unit) (struct.new $Unit))
381393
(global $true (ref $Unit) (struct.new $Unit))
382394
(func $truthy (param $v (ref null eq)) (result i32)
@@ -396,8 +408,8 @@
396408
(i64.shr_s (i64.shl (local.get $v) (i64.const 33)) (i64.const 33)))
397409
(then (ref.i31 (i32.wrap_i64 (local.get $v))))
398410
(else (struct.new $BoxI64 (local.get $v)))))
399-
;; Overflow arms are the open throw representation (0022 C, 0025 §4);
400-
;; signed-overflow checks per Hacker's Delight sign rules.
411+
;; Signed-overflow checks per Hacker's Delight sign rules; a taken
412+
;; arm throws ArithmeticException \"long overflow\" — class 1 (0027).
401413
(func $add (param $a (ref null eq)) (param $b (ref null eq)) (result (ref eq))
402414
(local $x i64) (local $y i64) (local $t i64)
403415
(local.set $x (call $unbox (local.get $a)))
@@ -406,7 +418,7 @@
406418
(if (i64.lt_s (i64.and (i64.xor (local.get $x) (local.get $t))
407419
(i64.xor (local.get $y) (local.get $t)))
408420
(i64.const 0))
409-
(then (unreachable)))
421+
(then (throw $clj-exn (struct.new $Exn (i32.const 1)))))
410422
(call $box (local.get $t)))
411423
(func $sub (param $a (ref null eq)) (param $b (ref null eq)) (result (ref eq))
412424
(local $x i64) (local $y i64) (local $t i64)
@@ -416,7 +428,7 @@
416428
(if (i64.lt_s (i64.and (i64.xor (local.get $x) (local.get $y))
417429
(i64.xor (local.get $x) (local.get $t)))
418430
(i64.const 0))
419-
(then (unreachable)))
431+
(then (throw $clj-exn (struct.new $Exn (i32.const 1)))))
420432
(call $box (local.get $t)))
421433
(func $mul (param $a (ref null eq)) (param $b (ref null eq)) (result (ref eq))
422434
(local $x i64) (local $y i64) (local $t i64)
@@ -429,9 +441,9 @@
429441
(then
430442
(if (i32.and (i64.eq (local.get $x) (i64.const -1))
431443
(i64.eq (local.get $y) (i64.const -9223372036854775808)))
432-
(then (unreachable)))
444+
(then (throw $clj-exn (struct.new $Exn (i32.const 1)))))
433445
(if (i64.ne (i64.div_s (local.get $t) (local.get $x)) (local.get $y))
434-
(then (unreachable)))))
446+
(then (throw $clj-exn (struct.new $Exn (i32.const 1)))))))
435447
(call $box (local.get $t)))
436448
;; quot: y = -1 wraps like the JVM's long division (MIN / -1 is MIN,
437449
;; silently); y = 0 takes the native division trap, trap-table row 1.

‎test/cljwit/corpus_test.clj‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,32 @@
8787
(throw (ex-info "the node corpus lane itself failed" {:err err}))
8888
(str/starts-with? line "result ") {:value (subs line 7)}
8989
(str/starts-with? line "trap ") {:trap (subs line 5)}
90+
;; An uncaught Clojure throw, class-precise on this lane (0027).
91+
(str/starts-with? line "exn ") {:exn (parse-long (subs line 4))}
9092
:else (throw (ex-info (str "unrecognized corpus lane output: " (pr-str line)) {})))))
9193

9294
;; --- comparison -------------------------------------------------------------
9395

9496
(defn- trap-matches?
9597
"True when a trap-table row licenses comparing this oracle throw equal
96-
to this engine trap (0022 B.5)."
98+
to this engine trap (0022 B.5). A row without a pattern for this
99+
engine licenses nothing on it."
97100
[engine {:keys [message] :as oracle} trap]
98101
(boolean (some (fn [row]
99102
(and (= (:class row) (:throw oracle))
100103
(str/includes? (or message "") (:jvm-message row))
101-
(str/includes? trap (get row engine))))
104+
(when-let [pat (get row engine)]
105+
(str/includes? trap pat))))
106+
trap-table)))
107+
108+
(defn- exn-matches?
109+
"The class-precise half (0027): a lane that read the thrown payload's
110+
class enum compares through a row's :exn-class."
111+
[{:keys [message] :as oracle} exn-class]
112+
(boolean (some (fn [row]
113+
(and (= (:class row) (:throw oracle))
114+
(str/includes? (or message "") (:jvm-message row))
115+
(= (:exn-class row) exn-class)))
102116
trap-table)))
103117

104118
(defn- out-of-contract?
@@ -121,6 +135,16 @@
121135
") but no corpus/trap_table.edn row matches the trap: "
122136
(str/trim (:trap lane))))
123137

138+
(and (contains? oracle :throw) (contains? lane :exn))
139+
(is (exn-matches? oracle (:exn lane))
140+
(str label ": oracle threw " (:throw oracle) " (" (:message oracle)
141+
") but no trap-table row carries :exn-class " (:exn lane)))
142+
143+
(and (contains? oracle :value) (contains? lane :exn))
144+
(is false
145+
(str label ": oracle returned " (pr-str (:value oracle))
146+
" but the lane threw exn class " (:exn lane)))
147+
124148
(and (contains? oracle :value) (contains? lane :trap))
125149
(is false
126150
(str label ": oracle returned " (pr-str (:value oracle))

0 commit comments

Comments
 (0)