|
| 1 | +# 0030 — S4's first slice: utf8 strings, a bump arena, per-export trampolines |
| 2 | + |
| 3 | +**Status:** proposed · 2026-07-30 · adversarially reviewed the same day. |
| 4 | +The review strengthened three sections with spec citations the first |
| 5 | +draft hedged around (the arena's safety is provable, not hoped; the |
| 6 | +worked core signature; utf8 as identity-class on this host today), made |
| 7 | +the echo slice's honest scope explicit, and caught a delegated figure |
| 8 | +absorbed unverified — the survey said 87 round-trip assertions, the |
| 9 | +test says 80 — four citations were spot-checked and this number was |
| 10 | +not among them; the fifth check is the one that mattered. |
| 11 | + |
| 12 | +## The question |
| 13 | + |
| 14 | +S4's premise is verified (`bb spike-s4`: a cljwit-compiled component |
| 15 | +answers 6765 through `cljwit.host`), and the entry survey re-read the |
| 16 | +Canonical ABI at component-model `73b7ad51` (2026-07-28), re-verified |
| 17 | +`0007`'s frontier by running it, and read the two closest prior arts |
| 18 | +(Kotlin's WasmGC→component pipeline, zwasm's lift/lower layer). It |
| 19 | +ended with exactly three decisions the first real slice cannot dodge: |
| 20 | +the string encoding and what stands behind it on the guest heap, the |
| 21 | +`cabi_realloc` design, and the codegen strategy. This note takes them. |
| 22 | +Survey citations live here so the claims stay checkable; four were |
| 23 | +spot-checked verbatim before this note was written. |
| 24 | + |
| 25 | +## The decision |
| 26 | + |
| 27 | +1. **String encoding: `utf8`, declared in every `canon lift`.** The |
| 28 | + spec default (Explainer.md:1401 — "If no `string-encoding` option |
| 29 | + is specified, the default is `utf8`"), Kotlin's shipped choice |
| 30 | + (its wit-bindgen fork's `STRING_TO_MEM` is |
| 31 | + `encodeToByteArray()` — utf8 — at kotlin/src/lib.rs:708-709), and |
| 32 | + zwasm's staging lesson (debt D-502: utf8-only shipped and tested |
| 33 | + for weeks; the late pain was threading the option, not the codec). |
| 34 | + `latin1+utf16` — which the Explainer (line 1398) pitches as the |
| 35 | + JVM-family encoding — is **named as the revisit**, gated on the |
| 36 | + day cljwit has a general string heap representation whose code |
| 37 | + units are UTF-16-shaped; deciding it now would couple this slice |
| 38 | + to an S5 decision nothing measures yet. And the double-transcode |
| 39 | + worry resolves in utf8's favor *today* *(review)*: `cljwit.host` |
| 40 | + already feeds wasmtime UTF-8 bytes on every string path |
| 41 | + (`host.clj` 236/385 lower, 143/249/396 lift), so a `utf8` guest |
| 42 | + makes the host-side transcode identity-class, while |
| 43 | + `latin1+utf16` would *add* one on this exact path. The B6-class |
| 44 | + measurement belongs to the revisit, not to licensing this slice. |
| 45 | +2. **The v0 guest string is boundary-only: utf8 bytes in |
| 46 | + `(array i8)`.** No general string type lands here — literals stay |
| 47 | + out of the compile slice until S5 — but the marshalling layer needs |
| 48 | + *something* to lift into and lower out of, and a byte array is the |
| 49 | + smallest thing the Canonical ABI's `(ptr, code-units)` pair maps |
| 50 | + onto. B6's wide-payload lever (`(array i64)`, 7.5×) is **not** |
| 51 | + taken yet: for payloads under ~30 KB the call dominates the copy |
| 52 | + (`0013`/B6), and taking the lever before a measured workload wants |
| 53 | + it would be `0002`'s incident shape. The lever stays licensed |
| 54 | + (`0008`) and priced (B6), one representation swap away. |
| 55 | +3. **`cabi_realloc` is a bump arena with a grow-last-block fast path, |
| 56 | + reset at each export entry.** The shape Kotlin's stdlib ships |
| 57 | + (`MemoryAllocation.kt`: bump allocation, grow-in-place when the |
| 58 | + growing block is the newest — exactly what `store_string`'s |
| 59 | + optimistic utf8 transcode calls for, CanonicalABI.md:2857-2860) |
| 60 | + and the shape zwasm found to be "the ONE real coupling into the |
| 61 | + core". The free point is the *next entry's* reset — and that is |
| 62 | + **provably conservative, not hoped** *(review)*: the Canonical ABI |
| 63 | + lifts results *inside* the call (`canon_lift` runs |
| 64 | + `lift_flat_values` before returning, CanonicalABI.md:3657-3670; |
| 65 | + `post-return` exists only to free afterwards, and is a no-op in |
| 66 | + wasmtime 47 per `0013`), so any conforming host has copied out |
| 67 | + before the call completes, and `cljwit.host` lifts eagerly on top |
| 68 | + of that. Reentrancy cannot reach the reset in the sync world: |
| 69 | + `may_enter` is cleared for the call's duration and reentry *traps* |
| 70 | + (CanonicalABI.md:127, 166-180). Dated constraint carried from the |
| 71 | + survey: spec #680 (2026-07-24) gives `realloc` fresh-thread |
| 72 | + semantics — no layout change, but nothing in our `realloc` may |
| 73 | + depend on context state. |
| 74 | +4. **Per-export specialized trampolines, emitted by the compiler — no |
| 75 | + generic interpreted layer.** What every wit-bindgen backend and |
| 76 | + zwasm's fused store/load converge on, and where the spill and |
| 77 | + asymmetry rules get baked in rather than consulted: flat params |
| 78 | + over 16 spill through memory, flat results over 1 spill through a |
| 79 | + single pointer (CanonicalABI.md:3138-3140, 3611-3614), and the |
| 80 | + **retptr asymmetry** zwasm recorded as a lesson — a lifted export |
| 81 | + returning a non-flat value takes no return-area param and *returns* |
| 82 | + the pointer; a lowered import takes the caller's return area *in* |
| 83 | + (zwasm lesson 2026-06-20, read in `.ref/`, quoted in the survey). |
| 84 | + The worked example, so no implementer re-derives it wrong |
| 85 | + *(review)*: a lifted sync `func(string) → string` has core |
| 86 | + signature **`(func (param i32 i32) (result i32))`** — the params |
| 87 | + are the caller's (ptr, code-units), and the single result is a |
| 88 | + pointer the *callee returns* to its own 4-aligned 8-byte |
| 89 | + (ptr, code-units) pair (`flatten_functype`'s lift branch, |
| 90 | + CanonicalABI.md:3142-3155). Emitting a `(param i32)` return-area |
| 91 | + export instead is zwasm's exact recorded bug. |
| 92 | +5. **The slice order**: `func(string) -> string` (echo — forces |
| 93 | + realloc, both directions, the retptr shape) and `list<u8>` first; |
| 94 | + flat records as the memcpy-class case after; variants/options |
| 95 | + later — each landing with round-trip coverage against |
| 96 | + `cljwit.host`, whose lanes already marshal every `0012` row from |
| 97 | + the host side (80 assertions in `roundtrip_test.clj` — measured, |
| 98 | + after the survey's figure of 87 failed verification *(review)*), |
| 99 | + so every guest-side slice has a finished counterpart to meet. |
| 100 | + Honest scope *(review)*: the echo body is **identity on an opaque |
| 101 | + value** — no Clojure-level string exists in the slice, so echo |
| 102 | + tests trampolines and realloc, not string semantics; lift into |
| 103 | + `(array i8)`, hand the same reference back, lower out. |
| 104 | +6. **The roadmap's sizing figure gains its decomposition**: zwasm's |
| 105 | + 8,574 lines verified by `wc -l`, but the lift/lower + realloc + |
| 106 | + handle-table core analogous to S4 is **~2,250 lines** — the rest |
| 107 | + is a component binary decoder, a validator, and WASI 0.3 async, |
| 108 | + all engine-side work this project delegates to wasm-tools and |
| 109 | + wasmtime. `doc/roadmap.md` carries a one-line pointer here. |
| 110 | + |
| 111 | +## Why |
| 112 | + |
| 113 | +- Every load-bearing external claim above was read at a pinned commit |
| 114 | + or run on the pinned toolchain on 2026-07-30, by a fresh-context |
| 115 | + survey whose citations carry paths and line numbers, four of them |
| 116 | + spot-checked verbatim afterward — the `0022`-incident protocol. |
| 117 | +- `0007` re-verified by running, unchanged: no `gc` canonopt exists in |
| 118 | + the grammar (Explainer.md:1381-1391); #525 is an open pre-proposal |
| 119 | + idle since 2026-04-23; wasm-tools 1.254 validates `(canon lift … gc)` |
| 120 | + behind `cm-gc`; wasmtime 47 panics on the call path at the same line |
| 121 | + `0007` recorded. The boundary is linear memory, still. |
| 122 | +- Kotlin — the closest production prior art — crosses the boundary |
| 123 | + with a per-byte loop and a bump arena. B6's 10× is the frontier, |
| 124 | + not a local deficiency; nobody has a bulk GC↔memory path. |
| 125 | + |
| 126 | +## Alternatives rejected |
| 127 | + |
| 128 | +- **`latin1+utf16` now.** The JVM-fit argument is real, but it binds |
| 129 | + the boundary encoding to a string representation that does not |
| 130 | + exist, and wasmtime transcodes host-side either way (`0012` L6). |
| 131 | + Revisit with the string heap type, as a measured decision. |
| 132 | +- **A generic runtime marshalling interpreter.** zwasm needed one — it |
| 133 | + is a host serving arbitrary components; a compiler knows every |
| 134 | + signature at build time, and the trampoline is exactly the code a |
| 135 | + generic layer would interpret. |
| 136 | +- **Taking B6's wide-representation lever now.** Unmeasured need; |
| 137 | + the swap is local when a workload demands it. |
| 138 | +- **`post-return` for arena reset.** Deprecated and a no-op in |
| 139 | + wasmtime 47 (`0013`, measured); resetting at next entry needs no |
| 140 | + cooperation that does not exist. |
| 141 | +- **Building `wit` parsing.** `wasm-tools component embed` consumes |
| 142 | + the WIT; the compiler only needs the WAT-level `canon` scaffolding |
| 143 | + `0007` already hand-verified. |
| 144 | + |
| 145 | +## What would falsify this |
| 146 | + |
| 147 | +- **The echo slice failing under `component new`'s export-name |
| 148 | + contract** (`memory`, `cabi_realloc`) — `0007` measured the |
| 149 | + requirement list once; the slice is its second, executable check. |
| 150 | +- **Arena-reset-at-entry surprising some host after all** — the spec |
| 151 | + says a conforming host lifts before the call returns |
| 152 | + (CanonicalABI.md:3657-3670), so this would mean a host violating |
| 153 | + the ABI; the slice's round-trip test covers it for `cljwit.host`, |
| 154 | + and a pipelined-calls test is named future coverage. |
| 155 | +- **#680's fresh-thread semantics acquiring teeth** (a wasmtime |
| 156 | + release actually running realloc on a fresh context) — nothing in |
| 157 | + the bump arena depends on context state, by construction; the |
| 158 | + constraint is carried so a future `realloc` never grows that |
| 159 | + dependency. |
| 160 | +- **The utf8 default colliding with a measured JVM-string workload** |
| 161 | + — that is the `latin1+utf16` revisit trigger, with B6-style |
| 162 | + numbers. |
0 commit comments