|
| 1 | +# 0024 — fn in the slice: closures, arities, and the first real mode divergence |
| 2 | + |
| 3 | +**Status:** proposed · 2026-07-30 · adversarially reviewed the same day, |
| 4 | +before implementation finished. The review verified the mechanics it |
| 5 | +attacked (constant `struct.new` globals, cross-rec-group subtyping, |
| 6 | +distinguishable null/cast traps, `RangeError` on V8 exhaustion, the three |
| 7 | +arity rules enforced by bare tools.analyzer, safe SOE recovery in the |
| 8 | +oracle) and changed four things, marked *(review)*: the depth pin's |
| 9 | +rationale re-measured for the real frame shape, row 2's class-only match |
| 10 | +made explicit, K's definition extended to call sites, and a per-mode |
| 11 | +corpus annotation for behaviour only one mode can express. |
| 12 | + |
| 13 | +## The question |
| 14 | + |
| 15 | +`0022` D contracts what `fn` must do (multi-arity ships with it, varargs |
| 16 | +carry an explicit rest-mode, no tail calls, the depth bands). It does not |
| 17 | +pin how a closure is laid out in WasmGC, how a call site reaches the code, |
| 18 | +or where `:dev` and `:prod` emission first produce different bytes. Those |
| 19 | +are this note's decisions — the first ones `0023`'s harness can check |
| 20 | +mode-divergently, since until now both modes emitted identical modules. |
| 21 | + |
| 22 | +## The decision |
| 23 | + |
| 24 | +1. **Function signatures.** `$sigN` is |
| 25 | + `(func (param (ref eq)) (param (ref null eq))×N (result (ref null eq)))` |
| 26 | + — parameter 0 is the closure itself, through which the body reads its |
| 27 | + captures. Uniform boxing per `0023`; unboxed signatures are a prod |
| 28 | + optimisation for later, not the baseline (`0022` C). |
| 29 | +2. **`$Fn` is a base struct with one nullable typed-function-ref field per |
| 30 | + arity**, 0..K where K is the module's maximum arity **over both method |
| 31 | + definitions and fn-value call sites** *(review)* — a higher-order site |
| 32 | + calling arity N > any defined method must find a null slot and take |
| 33 | + the classified out-of-contract trap, not emit a `struct.get` on a |
| 34 | + field that does not exist. Per-arity slots are `0004`'s measured |
| 35 | + shape — collapsing them to one funcref was measured there and |
| 36 | + rejected for 0.11 ns; the structure stands. |
| 37 | +3. **Each capture signature is its own subtype of `$Fn`, in its own rec |
| 38 | + group** (`0009`'s identity rule: unit-private types must not perturb |
| 39 | + shared groups). The callee's prologue casts parameter 0 to its leaf |
| 40 | + type once into a local (B4: casts are cheap by depth), and capture |
| 41 | + reads are `struct.get` on that local. Captures are by value, which is |
| 42 | + exact — Clojure has no `set!` on locals, so there is nothing mutable |
| 43 | + to alias. |
| 44 | +4. **Free variables are computed on uniquified names.** |
| 45 | + `clojure.tools.analyzer.passes.uniquify` is now scheduled inside |
| 46 | + `analyze-forms` — the pass-scheduler home `0022` E predicted for |
| 47 | + closure conversion. Partial shadowing |
| 48 | + (`(let [x 1] (fn [] (+ x (let [x 2] x))))`) makes name-based free-var |
| 49 | + computation wrong without it. |
| 50 | +5. **Multi-arity is slots in one struct; a missing arity is a null slot.** |
| 51 | + Calling it traps on the null `call_ref` — reported by the harness as |
| 52 | + out-of-contract until an `ArityException` representation exists (the |
| 53 | + throw representation is open, `0022` C). The three JVM arity rules are |
| 54 | + enforced at analysis where tools.analyzer sees the fn literal. |
| 55 | +6. **Varargs analyze but refuse to emit.** `:variadic?` methods are a |
| 56 | + loud out-of-slice error: the rest parameter is a seq, no seq |
| 57 | + representation exists, and `0022` D's rest-mode contract stays marked |
| 58 | + untested — its corpus entry enters the day printing does (`0022` B.3). |
| 59 | +7. **`recur` in a fn method rebinds the method's argument parameters** |
| 60 | + (never parameter 0, the closure) — the method's `:loop-id` registers |
| 61 | + them exactly as `loop` registers its bindings, same simultaneous |
| 62 | + stack-swap emission. |
| 63 | +8. **Calls.** The generic path is `0004`'s measured shape — reach the fn |
| 64 | + value (local, global, or fresh), `ref.cast (ref $Fn)`, `struct.get` |
| 65 | + the arity-N slot, `call_ref $sigN` — for every call whose target is a |
| 66 | + fn value. **The first mode divergence**: a top-level |
| 67 | + `(def f (fn …))` whose var is neither `^:dynamic` nor `^:redef` |
| 68 | + compiles in `:prod` to an **immutable global holding a constant |
| 69 | + `struct.new`**, and every call through the var to a **direct `call`** |
| 70 | + of the method — Clojure's own direct-linking rule and exclusion list |
| 71 | + (`0004`). In `:dev` the var stays a mutable `(ref null eq)` global |
| 72 | + assigned at eval, and calls go through the generic path — the var |
| 73 | + indirection `0009`'s open world requires. Re-`def` of a direct-linked |
| 74 | + fn in `:prod` is a loud refusal (`^:redef` is how you ask for |
| 75 | + redefinability, same as on the JVM). |
| 76 | +9. **Stack depth.** No tail calls, for parity (`0022` D). *(review — the |
| 77 | + bands re-measured for this note's actual frame shape, because `0022` |
| 78 | + D's 30–40k/10–20k numbers were for a minimal function and it predicted |
| 79 | + fatter frames itself)*: with the self param, prologue cast, slot load |
| 80 | + and `call_ref`, **wasmtime exhausts at ~8–10k, V8 at ~10.3–10.7k, and |
| 81 | + the oracle JVM itself at ~8.5–9.2k** for an eval'd recursive fn on the |
| 82 | + default test-JVM stack — the oracle is a band like any other lane. |
| 83 | + Depth-sensitive entries therefore pin **3,000 frames**, ~2.8× under |
| 84 | + the shallowest measured band. One entry exhausts the stack on purpose |
| 85 | + and adds **trap-table row 2**: `java.lang.StackOverflowError` — whose |
| 86 | + message is null, so **this row matches on class alone**, a recorded |
| 87 | + weakening of the table's jvm-message guard *(review)*; SOE also |
| 88 | + arrives *unwrapped* by `eval`, so the CompilerException unwrap is |
| 89 | + correctly a no-op here — ↔ wasmtime's "call stack exhausted" ↔ V8's |
| 90 | + `RangeError` ("Maximum call stack size exceeded"), which is *not* a |
| 91 | + `WebAssembly.RuntimeError`, so the V8 runner learns to classify |
| 92 | + `RangeError` as a trap. |
| 93 | +10. **Per-mode corpus entries** *(review)*. `:prod` refuses fn re-`def` |
| 94 | + (point 8), so the dev-mode behaviour it exists to preserve — |
| 95 | + redefinition taking effect — could never be exercised by entries |
| 96 | + that run in both modes. A corpus entry may carry `:modes [:dev]` |
| 97 | + (default: both); the fn-redef entry is dev-only by construction, |
| 98 | + and the `^:redef` entry covers both modes. |
| 99 | + |
| 100 | +## Why |
| 101 | + |
| 102 | +- Every load-bearing shape is inherited from a measured decision: the |
| 103 | + per-arity slots and the direct-linking exclusion rule from `0004`, the |
| 104 | + rec-group isolation from `0009`, cheap leaf casts from B4, boxed |
| 105 | + signatures from `0023`/`0022` C, the depth bands from `0022` D's |
| 106 | + measurements. |
| 107 | +- The divergence point (calls through vars, not `def` itself) is where |
| 108 | + Clojure's own direct linking draws it, so no new vocabulary or caveat |
| 109 | + list is invented — including its known behaviour that a direct-linked |
| 110 | + fn ignores redefinition, which here is a refusal instead of a silent |
| 111 | + staleness. |
| 112 | + |
| 113 | +## Alternatives rejected |
| 114 | + |
| 115 | +- **One funcref field plus arity dispatch in the callee.** `0004` |
| 116 | + measured the per-arity table against this and kept the table; an |
| 117 | + arity switch on every call buys one word per closure. |
| 118 | +- **Reusing `0004`'s full `$obj`/vtable-array layout now.** That layer |
| 119 | + exists for protocols and open user types (selector coloring over the |
| 120 | + whole program). A fn value's callee set is closed at creation; slots |
| 121 | + suffice. The two converge when protocols land, not before. |
| 122 | +- **A universal closure type with an `(ref null eq)` capture array.** |
| 123 | + Loses typed capture reads, adds a bounds-checked load per capture, and |
| 124 | + `0009` already decided per-signature types for sharing reasons. |
| 125 | +- **Implementing varargs against an ad-hoc pair/array type.** Every rest |
| 126 | + argument would be re-done when seqs land; `0022` D's rest-mode |
| 127 | + contract is about *marking*, which analysis already preserves. |
| 128 | +- **`return_call` for self-recursion now.** The toolchain supports it, |
| 129 | + but parity with the JVM's missing TCO is the deliberate baseline |
| 130 | + (`0022` D); tail-position marking lands with its consumer, post-S3. |
| 131 | +- **Direct-linking in `:dev` too.** Kills redefinition, which is the |
| 132 | + entire point of the dev mode's open world (`0009`). |
| 133 | + |
| 134 | +## What would falsify this |
| 135 | + |
| 136 | +- A measured prod-vs-dev call-cost gap too small to justify two paths — |
| 137 | + nothing here is a performance *claim*; the benchmark named by `0010` |
| 138 | + (coverage/guard precision) arrives with specialisation, and if direct |
| 139 | + linking buys nothing measurable the prod path simplifies to the |
| 140 | + generic one. |
| 141 | +- The null-slot arity trap colliding with a future `ArityException` |
| 142 | + row — expected; resolving it is part of the throw-representation |
| 143 | + decision (`0022` C), and the trap table gains the distinction then. |
| 144 | +- A capture set the uniquified-name computation gets wrong — one corpus |
| 145 | + entry exists specifically to catch partial shadowing. |
| 146 | +- `struct.new` with `ref.func` failing as a constant expression on |
| 147 | + either engine — checked at validation, would force prod fns back to |
| 148 | + start-time assignment (a smaller divergence, same call-site shape). |
0 commit comments