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

Commit f6cfe0d

Browse files
committed
Host imports do not break 0014 D or E, and the last commit over-claimed
That commit said imports "break both of 0014's per-call decisions". Measured, they break neither. A host callback that tries to call back into the instance currently executing gets wasm trap: cannot enter component instance so the component model forbids the nesting independently, which means the argument buffer 0014 E reuses cannot be clobbered by a nested call, and D's throw is correct rather than a false positive. Predicted before running -- the Canonical ABI's may_enter flag exists for exactly this -- and confirmed. What was genuinely wrong is the message, not the decision. One flag was doing two jobs and reporting both as contention: "the store is already in use by another call" sends the reader looking for a second thread that does not exist. The flag now records the thread holding it, so nesting says nesting (:cljwit/error :reentrant, naming the export that was running) and contention names the thread. Throwing before wasmtime traps is worth keeping either way, because a trap poisons the instance and every later call then fails with the same message, hiding the cause. The spike also learned to read wasmtime's error text, which is how "re-entrant func_call failed" became the sentence above. That is the second time in two commits that the message was the whole finding. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 2b04d43 commit f6cfe0d

4 files changed

Lines changed: 74 additions & 16 deletions

File tree

‎dev/cljwit/spike/host_import.clj‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,20 @@
4848
(FunctionDescriptor/ofVoid ls))
4949
(into-array java.lang.foreign.Linker$Option []))))
5050
call (fn [^MethodHandle mh & as] (.invokeWithArguments mh ^java.util.List (vec as)))
51-
ok! (fn [what v] (when-not (.equals MemorySegment/NULL ^MemorySegment v)
52-
(throw (ex-info (str what " failed") {})))
51+
emsg (fx "wasmtime_error_message" nil ADDR ADDR)
52+
edel (fx "wasmtime_error_delete" nil ADDR)
53+
;; Reading wasmtime's own message is the difference between a
54+
;; bounded question and a guess — see 0016.
55+
ok! (fn [what v]
56+
(when-not (.equals MemorySegment/NULL ^MemorySegment v)
57+
(let [nm ^MemorySegment (.allocate arena (long 16))]
58+
(call emsg v nm)
59+
(let [n (.get nm I64 (long 0))
60+
p ^MemorySegment (.get nm ADDR (long 8))
61+
b (byte-array n)]
62+
(MemorySegment/copy (.reinterpret p n) I8 0 b 0 (int n))
63+
(call edel v)
64+
(throw (ex-info (str what ": " (String. b "UTF-8")) {})))))
5365
v)
5466
cstr (fn [^String t]
5567
(let [b (.getBytes t "UTF-8")
@@ -58,6 +70,8 @@
5870
(.set s I8 (long (alength b)) (byte 0))
5971
s))
6072
seen (atom [])
73+
reenter (atom nil) ; set once `run` is resolvable
74+
reentry (atom :not-tried)
6175
;; The host function the guest imports. Reads one u32 out of the
6276
;; argument val and writes one back — the same layout every other
6377
;; lane in this repo uses.
@@ -67,6 +81,10 @@
6781
I32 (long UNION))
6882
out ^MemorySegment (.reinterpret ^MemorySegment res (long VAL))]
6983
(swap! seen conj v)
84+
;; Does the component model let a host callback call
85+
;; back into the instance that is currently executing?
86+
(when-let [f @reenter]
87+
(reset! reentry (try (f 1) (catch Exception ex (ex-message ex)))))
7088
(.set out I8 (long 0) (byte KIND-U32))
7189
(.set out I32 (long UNION) (int (* 2 v)))
7290
MemorySegment/NULL)))
@@ -119,10 +137,21 @@
119137
(throw (ex-info "no export run" {})))
120138
args ^MemorySegment (.allocate arena (long VAL))
121139
res ^MemorySegment (.allocate arena (long VAL))]
140+
(reset! reenter
141+
(fn [n]
142+
(let [a2 ^MemorySegment (.allocate arena (long VAL))
143+
r2 ^MemorySegment (.allocate arena (long VAL))]
144+
(.set a2 I8 (long 0) (byte KIND-U32))
145+
(.set a2 I32 (long UNION) (int n))
146+
(ok! "re-entrant func_call"
147+
(call (fx "wasmtime_component_func_call" ADDR ADDR ADDR ADDR I64 ADDR I64)
148+
f ctx a2 (long 1) r2 (long 1)))
149+
(.get r2 I32 (long UNION)))))
122150
(.set args I8 (long 0) (byte KIND-U32))
123151
(.set args I32 (long UNION) (int 20))
124152
(ok! "func_call" (call (fx "wasmtime_component_func_call" ADDR ADDR ADDR ADDR I64 ADDR I64)
125153
f ctx args (long 1) res (long 1)))
126154
(println "run(20) =" (.get res I32 (long UNION))
127155
" host saw" @seen
128-
" (expected 41 and [20])")))))
156+
" (expected 41 and [20])")
157+
(println "re-entry from the host callback:" (pr-str @reentry))))))

‎doc/design/0014-cljwit-host-api.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ in-call flag, thrown on contention with the name of the thread already inside.
7979
It guards **every** entry, not only calls: `close`, and dropping a lifted
8080
`own<T>` handle, both take the context and are the same memory-unsafety.
8181

82+
**Amended 2026-07-30, after host imports were shown to work.** The flag does
83+
two jobs, and only one of them is contention. A host import calling back into
84+
the instance that is executing is *nesting*, not concurrency — and the
85+
component model forbids that too: measured, `wasm trap: cannot enter component
86+
instance`. So throwing is right, and throwing *before* wasmtime traps is better,
87+
because a trap poisons the instance and every later call fails with the same
88+
message, hiding the cause. The flag now records the thread holding it, so
89+
re-entry says so (`:cljwit/error :reentrant`) instead of blaming a second
90+
thread that does not exist.
91+
8292
### E. Arguments reuse a buffer; results are lifted eagerly and never retained
8393

8494
A result payload is **invalid after the next call on that function**. So every
@@ -98,6 +108,10 @@ recycles its own result allocation: measured, 300k `result` calls grow RSS by
98108
Argument buffers are built once per export and reused, which is safe for the
99109
same reason `0013` gives: the values are written immediately before the call.
100110

111+
**This survives host imports**, which was not obvious and is now measured: the
112+
nested call that would clobber the buffer cannot happen, because a component
113+
instance cannot be re-entered at all.
114+
101115
## Why
102116

103117
**A, because reflection is complete and codegen is not reversible.** The nested

‎doc/status.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ _Short by design, and printed at every session start — so findings live in
1717
express today. What is left of `0012` is `map`, `list<T,N>`,
1818
`stream`/`future` and `error-context`, none of which are in a shipped
1919
release.
20-
2. **Host imports.** `0014` names them as its most-likely-to-fire falsifier,
21-
WASI requires them, and nothing has touched them. They also invalidate
22-
`0014` E's argument-buffer reuse, which is safe today only because no guest
23-
can call back.
20+
2. **Host imports.** The mechanism works — an FFM upcall stub reaches Clojure
21+
from inside a component call (`bb spike-import`), pure Clojure, no C shim.
22+
What is left is the `cljwit.host` API for declaring them. `0014` D and E
23+
both **survive**: a host callback cannot re-enter the instance that is
24+
executing (`wasm trap: cannot enter component instance`), so the nested
25+
call that would clobber the argument buffer cannot happen.
2426
3. **`0012`'s `ex-data` contract.** It promises a WIT type name that reflection
2527
cannot supply; `0015` declined to be the codegen layer that would, so the
2628
contract has to shrink instead.

‎src/cljwit/host.clj‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
(:import [java.lang.foreign Arena Linker SymbolLookup MemorySegment
1818
FunctionDescriptor ValueLayout]
1919
[java.lang.invoke MethodHandle]
20-
[java.util.concurrent.atomic AtomicBoolean]))
20+
[java.util.concurrent.atomic AtomicBoolean AtomicReference]))
2121

2222
(set! *warn-on-reflection* true)
2323

@@ -579,7 +579,7 @@
579579
nil))
580580

581581
(deftype Instance [^Artifact artifact ^Arena arena exports aliases sigs
582-
^MemorySegment store ^AtomicBoolean closed ^AtomicBoolean in-call
582+
^MemorySegment store ^AtomicBoolean closed ^AtomicReference in-call
583583
registry]
584584
java.lang.AutoCloseable
585585
(close [_]
@@ -588,7 +588,7 @@
588588
;; then throws, leaving the store never deleted and the arena never closed,
589589
;; with every retry a silent no-op because the CAS already won.
590590
(when-not (.get closed)
591-
(when-not (.compareAndSet in-call false true)
591+
(when (some? (.compareAndExchange ^AtomicReference in-call nil (Thread/currentThread)))
592592
(throw (ex-info "cannot close while a call is in flight"
593593
{:cljwit/error :concurrent-use})))
594594
(when (.compareAndSet closed false true)
@@ -663,7 +663,7 @@
663663
and reused; the result is lifted eagerly and nothing backed by it escapes
664664
(`0014` E)."
665665
[api ^Arena arena ^MemorySegment ctx ^MemorySegment f sig
666-
^AtomicBoolean closed ^AtomicBoolean in-call nm rt]
666+
^AtomicBoolean closed ^AtomicReference in-call nm rt]
667667
(let [ptypes (mapv (comp lower-fn second) (:params sig))
668668
n (count ptypes)
669669
rlift (when (:result sig) (lift-fn (:result sig) rt))
@@ -679,9 +679,22 @@
679679
(when-not (= n (count vs))
680680
(throw (ex-info (str nm " takes " n " argument(s)")
681681
{:cljwit/error :wrong-arity :cljwit/export nm})))
682-
(when-not (.compareAndSet in-call false true)
683-
(throw (ex-info "the store is already in use by another call"
684-
{:cljwit/error :concurrent-use :cljwit/export nm})))
682+
;; 0014 D is one flag doing two jobs. Which one it is depends on whether
683+
;; *this* thread already holds it: a host import calling back into the
684+
;; instance that is executing is nesting, which the component model also
685+
;; forbids ("wasm trap: cannot enter component instance"), and saying
686+
;; "another call" about it sends the reader looking for a second thread.
687+
(let [me (Thread/currentThread)
688+
holder (.compareAndExchange ^AtomicReference in-call nil me)]
689+
(when (some? holder)
690+
(throw (ex-info (if (identical? holder me)
691+
(str "re-entered the store while " nm
692+
" was running — a component instance cannot be"
693+
" re-entered, including from a host import")
694+
(str "the store is in use by " (.getName ^Thread holder)))
695+
{:cljwit/error (if (identical? holder me)
696+
:reentrant :concurrent-use)
697+
:cljwit/export nm}))))
685698
(try
686699
(with-open [scratch (Arena/ofConfined)]
687700
(dotimes [i n]
@@ -700,7 +713,7 @@
700713
(when (seq ps)
701714
(reset! (:pending rt) [])
702715
(run! (fn [p] (invoke (:res-delete api) p)) ps)))
703-
(.set in-call false))))))
716+
(.set ^AtomicReference in-call nil))))))
704717

705718
(defn instantiate
706719
"Instantiates a compiled component. Cheap — tens of microseconds — so a
@@ -719,7 +732,7 @@
719732
(invoke (:instantiate api) clink ctx (.-ptr art) inst))
720733
ct (invoke (:comp-type api) (.-ptr art))
721734
closed (AtomicBoolean. false)
722-
in-call (AtomicBoolean. false)
735+
in-call (AtomicReference. nil)
723736
;; 0016 D: one call can yield handles the caller never destructured, so
724737
;; the instance keeps the list and closes what is left before deleting
725738
;; the store a handle's context points into.

0 commit comments

Comments
 (0)