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

Commit 120311e

Browse files
committed
String results from host imports, and the measurement that justifies malloc
0017 C says wasmtime takes ownership of what an import callback writes and frees it, so a string result cannot come from a java.lang.foreign.Arena. That came from the review; this commit verifies it, and the shape of the failure is the finding: arena pointer, one call -> "ababab", rc=0 arena pointer, 2000 calls -> Abort trap: 6, rc=134 One call succeeds. A test that called once would have licensed the corruption and reported a green gate, which is why the test loops 500 times. So the import direction gets libc's malloc -- the only allocator this library binds, and only here. dev/resources/imps.{wit,wat} is a guest whose import returns a string and whose export relays it, so both directions of a string cross in one call. Two of the assertions I wrote first were nonsense and the suite caught them: the host function returns n copies of "ab", so expecting "日本語" from it, and expecting 500 distinct results from 500 identical calls, were both my errors rather than the code's. Aggregate results -- list, record, option, variant -- need the same allocator change threaded through lower-fn and are still refused at instantiate with the kind named. 101 assertions. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 0d0c736 commit 120311e

5 files changed

Lines changed: 81 additions & 5 deletions

File tree

‎dev/resources/imps.wat‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
;; The guest half of dev/resources/imps.wit. A string result flattens to a
2+
;; return area the callee writes (ptr, len) into, in both directions.
3+
(module
4+
(import "local:imps/host@0.1.0" "mk" (func $mk (param i32) (param i32)))
5+
(memory (export "memory") 1)
6+
(global $next (mut i32) (i32.const 1024))
7+
(func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32)
8+
(local $p i32)
9+
(local.set $p (global.get $next))
10+
(global.set $next (i32.add (local.get $p) (local.get 3)))
11+
(local.get $p))
12+
(func (export "run") (param $n i32) (result i32)
13+
(call $mk (local.get $n) (i32.const 128))
14+
(i32.store (i32.const 160) (i32.load (i32.const 128)))
15+
(i32.store offset=4 (i32.const 160) (i32.load offset=4 (i32.const 128)))
16+
(i32.const 160)))

‎dev/resources/imps.wit‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A host import that returns a string, which is where the two directions stop
2+
// being mirror images: wasmtime *frees* what the callback writes (`0017` C).
3+
package local:imps@0.1.0;
4+
interface host { mk: func(n: u32) -> string; }
5+
world w { import host; export run: func(n: u32) -> string; }

‎doc/design/0017-host-imports.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ returning a pointer into static storage aborts with
7676
`POINTER_BEING_FREED_WAS_NOT_ALLOCATED` inside
7777
`wasmtime_component_linker_instance_add_func`'s closure.
7878

79-
`host.clj`'s `lower-fn` allocates **every** payload from a
80-
`java.lang.foreign.Arena` — string bytes, list/tuple/record element buffers,
79+
**Confirmed here, and the shape of the failure is the finding.** With an Arena
80+
pointer, *one call succeeds*; 2000 abort the process (`rc=134`). A test that
81+
called once would have licensed the corruption. `host.clj`'s `lower-fn`
82+
allocates **every** payload from a `java.lang.foreign.Arena` — string bytes, list/tuple/record element buffers,
8183
flags name vectors, and the boxed vals behind `option`/`result`/`variant`.
8284
Handing wasmtime an Arena pointer to `free()` is heap corruption. The import
8385
direction needs `malloc` and `wasmtime_component_val_new`, so `lower-fn` needs

‎src/cljwit/host.clj‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@
155155
:err-message (b "wasmtime_error_message" nil ADDR ADDR)
156156
:err-delete (b "wasmtime_error_delete" nil ADDR)
157157
:err-new (b "wasmtime_error_new" ADDR ADDR)
158+
;; 0017 C: wasmtime frees what an import callback writes, so a string
159+
;; result cannot come from an Arena. libc's malloc is the allocator it
160+
;; expects, and this is the only place in the library that needs one.
161+
:malloc (let [seg ^MemorySegment (.orElseThrow (.find (.defaultLookup linker) "malloc"))]
162+
(.downcallHandle linker seg
163+
(FunctionDescriptor/of ADDR (into-array java.lang.foreign.MemoryLayout [I64]))
164+
(into-array java.lang.foreign.Linker$Option [])))
158165
:linker-root (b "wasmtime_component_linker_root" ADDR ADDR)
159166
:li-add-inst (b "wasmtime_component_linker_instance_add_instance" ADDR ADDR ADDR I64 ADDR)
160167
:li-add-func (b "wasmtime_component_linker_instance_add_func" ADDR ADDR ADDR I64 ADDR ADDR ADDR)}))
@@ -549,15 +556,33 @@
549556
;; `malloc` rather than from an Arena (0017 C), which is not built yet. Refusing
550557
;; at instantiate with the kind named beats a wrong answer or a corrupt heap.
551558
(def ^:private IMPORTABLE
552-
#{:bool :s8 :u8 :s16 :u16 :s32 :u32 :s64 :u64 :f32 :f64 :char})
559+
#{:bool :s8 :u8 :s16 :u16 :s32 :u32 :s64 :u64 :f32 :f64 :char :string})
553560

554561
(defn- import-stub
555562
"One FFM upcall stub for one host import. Lifts the arguments, calls `f`,
556563
lowers the result, and converts anything thrown into a wasmtime error --
557564
a JVM exception unwinding through native frames exits the VM (0017 D)."
558565
[api ^Arena arena sig f nm dead]
559-
(let [lifts (mapv (fn [[_ t]] (scalar-lift t)) (:params sig))
560-
lower (some-> (:result sig) scalar-lower)
566+
(let [lifts (mapv (fn [[_ t]] (if (= :string t)
567+
(fn [^MemorySegment seg] (read-str seg UNION))
568+
(scalar-lift t)))
569+
(:params sig))
570+
rt (:result sig)
571+
lower (cond
572+
(nil? rt) nil
573+
(= :string rt)
574+
(fn [_ ^MemorySegment seg ^String v]
575+
;; malloc, not the arena: wasmtime takes ownership and frees.
576+
(let [b (.getBytes v "UTF-8")
577+
n (max 1 (alength b))
578+
buf ^MemorySegment (.reinterpret
579+
^MemorySegment (invoke (:malloc api) (long n))
580+
(long n))]
581+
(MemorySegment/copy ^bytes b 0 buf I8 0 (alength b))
582+
(.set seg I8 (long 0) (byte (VAL-KIND :string)))
583+
(.set seg I64 (long (+ UNION STR-SIZE)) (long (alength b)))
584+
(.set seg ADDR (long (+ UNION STR-DATA)) buf)))
585+
:else (scalar-lower rt))
561586
cb (reify ImportCallback
562587
(call [_ _data _ctx _ty args _n res _nres]
563588
(try

‎test/cljwit/host_test.clj‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,31 @@
411411
(is (= :instance-dead (:cljwit/error (ex-data ex)))
412412
"and the next call says the instance is dead rather than reporting a trap"))))))
413413
(finally (.delete ^File f))))))
414+
415+
(deftest host-imports-returning-a-string
416+
;; `0017` C: the two directions are not mirror images. wasmtime takes
417+
;; ownership of what an import callback writes and frees it, so a string
418+
;; result must come from `malloc`, not from an `Arena`.
419+
;;
420+
;; Measured, and the shape of the measurement is the point: with an Arena
421+
;; pointer **one call succeeds** and 2000 abort the process (rc=134). A test
422+
;; that called once would have licensed the corruption, which is why this one
423+
;; loops.
424+
(if-not lib
425+
(println "CLJWIT_WASMTIME_LIB unset — skipping string-import test")
426+
(let [f (build-component! "imps")]
427+
(try
428+
(with-open [e (host/engine)]
429+
(with-open [a (host/compile e (io/file f))]
430+
(with-open [i (host/instantiate
431+
a {:imports {"local:imps/host@0.1.0#mk"
432+
(fn [n] (apply str (repeat n "ab")))}})]
433+
(testing "a string crosses in both directions within one call"
434+
(is (= "ababab" ((i "run") 3)))
435+
(is (= "" ((i "run") 0)))
436+
(is (= "ab" ((i "run") 1))))
437+
(testing "and keeps working, which is what an Arena pointer would not"
438+
(is (every? (fn [n] (= (apply str (repeat n "ab")) ((i "run") n)))
439+
(map (fn [k] (inc (mod k 9))) (range 500)))
440+
"500 calls, each result freed by wasmtime")))))
441+
(finally (.delete ^File f))))))

0 commit comments

Comments
 (0)