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

Commit cf43be4

Browse files
committed
Echo a string across the boundary, and drop the third cost estimate
status.md said the aggregate half of 0012's test needed a wit-bindgen-generated guest, since string and friends require linear memory and cabi_realloc. Checked the premise before building on it. It is wrong. An *echo* copies nothing: the host has already lowered the string into the guest's memory, so echoing it means handing back the same (ptr, len). The whole guest is 25 lines of WAT -- a bump-allocator cabi_realloc, an exported memory, and a function that stores the pair and returns its address. wasm-tools component new does the rest. wasmtime run --invoke 'echo-string("hello")' -> "hello" So the test now covers string too: 46 assertions, empty strings, multi-byte UTF-8, an embedded NUL, a 1000-character string, and L6's unpaired surrogate -- which does not survive, though not for the reason 0012 gave (UTF-8 encoding substitutes rather than failing, so it comes back changed rather than being rejected). **This note has now over-costed its own test three times**: "thousands of lines of canonical ABI", then "needs a wit-bindgen guest", then 25 lines of WAT. Over-estimating the cost of the check is how it kept talking itself out of running it, and that is recorded in 0012 rather than quietly fixed. Renamed scalar_roundtrip_test to roundtrip_test, since it is no longer scalars. Remaining: record, list, variant, option, result -- each needs its own ABI shape in the guest and nothing this repo lacks. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 1ba8376 commit cf43be4

3 files changed

Lines changed: 73 additions & 11 deletions

File tree

dev/resources/echo.wat

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@
22
;; leaves Clojure, crosses the canonical ABI twice, and comes back, so
33
;; doc/design/0012's mapping is checked rather than asserted.
44
(module
5+
;; Aggregates arrive already lowered into our memory by the host, so echoing
6+
;; one means handing back the same (ptr, len) — no copy. What it does need is
7+
;; the two things doc/design/0007 named: an exported memory and cabi_realloc.
8+
(memory (export "memory") 1)
9+
(global $next (mut i32) (i32.const 16))
10+
(func (export "cabi_realloc")
11+
(param $old i32) (param $old-sz i32) (param $align i32) (param $new-sz i32)
12+
(result i32)
13+
(local $p i32)
14+
(local.set $p (i32.and (i32.add (global.get $next)
15+
(i32.sub (local.get $align) (i32.const 1)))
16+
(i32.xor (i32.sub (local.get $align) (i32.const 1))
17+
(i32.const -1))))
18+
(global.set $next (i32.add (local.get $p) (local.get $new-sz)))
19+
(local.get $p))
20+
21+
;; A result wider than one core value comes back through a pointer the callee
22+
;; returns; here that is the fixed eight bytes at 0.
23+
(func (export "echo-string") (param $ptr i32) (param $len i32) (result i32)
24+
(i32.store (i32.const 0) (local.get $ptr))
25+
(i32.store (i32.const 4) (local.get $len))
26+
(i32.const 0))
27+
528
(func (export "echo-bool") (param $v i32) (result i32) (local.get $v))
629
(func (export "echo-s32") (param $v i32) (result i32) (local.get $v))
730
(func (export "echo-u64") (param $v i64) (result i64) (local.get $v))

dev/resources/echo.wit

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ world echo {
1010
export echo-f32: func(v: f32) -> f32;
1111
export echo-f64: func(v: f64) -> f64;
1212
export echo-char: func(v: char) -> char;
13+
14+
/// The first aggregate row. Needs an exported memory and cabi_realloc, per
15+
/// doc/design/0007 — but not a Rust toolchain, which is what the note first
16+
/// assumed and what this disproves.
17+
export echo-string: func(v: string) -> string;
1318
}
Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
(ns cljwit.scalar-roundtrip-test
2-
"Turns `doc/design/0012`'s scalar rows from a table into a check.
1+
(ns cljwit.roundtrip-test
2+
"Turns `doc/design/0012`'s mapping from a table into a check.
33
4-
Every WIT type whose canonical ABI needs no linear memory can be echoed by a
5-
hand-written WAT guest, so the mapping is testable today without the Rust or
6-
C toolchain the aggregate types will need. A value leaves Clojure, crosses
7-
the canonical ABI twice, and comes back.
4+
A value leaves Clojure, crosses the canonical ABI twice, and comes back. The
5+
guest is hand-written WAT — including for `string`, which needs an exported
6+
memory and `cabi_realloc` but, it turns out, no Rust toolchain: an echo hands
7+
back the pointer the host already lowered into its memory, so the whole guest
8+
is a bump allocator and a stored pair.
89
910
The lossy mappings `0012` letters — L2 `u64` above 2^63, L3 `f32`
1011
narrowing, L4 NaN canonicalisation, L5 the `char` surrogate hole — were
@@ -14,7 +15,10 @@
1415
1516
Skips when wasmtime is not present, the same way `bb lint` skips without
1617
clj-kondo: the gate stays runnable on a machine that cannot run it, and CI
17-
has the full toolchain."
18+
has the full toolchain.
19+
20+
Still missing: `record`, `list`, `variant`, `option`, `result`. Each needs
21+
its own ABI shape in the guest; none needs a toolchain this repo lacks."
1822
(:require [clojure.java.io :as io]
1923
[clojure.java.shell :as shell]
2024
[clojure.test :refer [deftest is testing]])
@@ -34,7 +38,12 @@
3438
;; inferred: wasmtime_component_val_t is 32 bytes, kind:u8 at 0, union at 8.
3539
(def ^:private VAL 32)
3640
(def ^:private UNION 8)
37-
(def ^:private KIND {:bool 0 :s32 5 :u64 8 :f32 9 :f64 10 :char 11})
41+
(def ^:private KIND {:bool 0 :s32 5 :u64 8 :f32 9 :f64 10 :char 11 :string 12})
42+
43+
;; wasm_name_t is {size_t size; char *data;} — 16 bytes, size at 0, data at 8 —
44+
;; measured against the pinned headers, and it sits at the union offset.
45+
(def ^:private STR-SIZE 0)
46+
(def ^:private STR-DATA 8)
3847

3948
(def ^:private lib (System/getenv "CLJWIT_WASMTIME_LIB"))
4049

@@ -132,7 +141,12 @@
132141
(.set args I32 (long UNION) (int v)))
133142
:u64 (.set args I64 (long UNION) (long v))
134143
:f32 (.set args F32 (long UNION) (float v))
135-
:f64 (.set args F64 (long UNION) (double v)))
144+
:f64 (.set args F64 (long UNION) (double v))
145+
:string (let [b (.getBytes ^String v "UTF-8")
146+
buf ^MemorySegment (.allocate arena (long (max 1 (alength b))))]
147+
(MemorySegment/copy ^bytes b 0 buf I8 0 (alength b))
148+
(.set args I64 (long (+ UNION STR-SIZE)) (long (alength b)))
149+
(.set args ADDR (long (+ UNION STR-DATA)) buf)))
136150
(ok! (str "call " export) (call fcall f ctx args (long 1) res (long 1)))
137151
(ok! (str "post_return " export) (call fpost f ctx))
138152
(case kind
@@ -141,7 +155,12 @@
141155
:char (.get res I32 (long UNION))
142156
:u64 (.get res I64 (long UNION))
143157
:f32 (.get res F32 (long UNION))
144-
:f64 (.get res F64 (long UNION)))))))
158+
:f64 (.get res F64 (long UNION))
159+
:string (let [n (.get res I64 (long (+ UNION STR-SIZE)))
160+
p ^MemorySegment (.get res ADDR (long (+ UNION STR-DATA)))
161+
b (byte-array n)]
162+
(MemorySegment/copy (.reinterpret p n) I8 0 b 0 (int n))
163+
(String. b "UTF-8")))))))
145164

146165
(defn- with-echo
147166
"Builds the component, opens it, and calls `f` with the echo function. A
@@ -150,7 +169,7 @@
150169
hook is what caught that."
151170
[f]
152171
(if-not lib
153-
(println "CLJWIT_WASMTIME_LIB unset — skipping scalar round-trip test")
172+
(println "CLJWIT_WASMTIME_LIB unset — skipping round-trip test")
154173
(let [c (build-component!)]
155174
(try
156175
(with-open [a (Arena/ofConfined)]
@@ -204,6 +223,21 @@
204223
(is (= (Double/doubleToRawLongBits -0.0) (Double/doubleToRawLongBits out))
205224
"the sign of zero does survive"))))))
206225

226+
(deftest strings-round-trip
227+
(with-echo
228+
(fn [echo]
229+
(testing "the first aggregate row — memory and cabi_realloc, no Rust toolchain"
230+
(doseq [v ["" "hello" "日本語" "a\u0000b" (apply str (repeat 1000 "x"))]]
231+
(is (= v (echo "echo-string" :string v)) (str "string " (pr-str v)))))
232+
(testing "L6 — a string carrying an unpaired surrogate is not a WIT string"
233+
;; Java lets one exist; WIT's string is a sequence of scalar values.
234+
;; UTF-8 encoding replaces it rather than failing, so what comes back is
235+
;; not what went in — recorded because 0012 predicted it could not be
236+
;; lowered at all.
237+
(let [lone (str (char 0xD800))]
238+
(is (not= lone (echo "echo-string" :string lone))
239+
"an unpaired surrogate does not survive the boundary"))))))
240+
207241
(deftest char-boundaries
208242
(with-echo
209243
(fn [echo]

0 commit comments

Comments
 (0)