|
| 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. |
0 commit comments