Skip to content

Commit de65314

Browse files
committed
0018 A and B: host-defined resources through cljwit.host
(host/instantiate art {:resources {"local:hres/host@0.1.0#token" {:drop close!}} :imports {"...#mint" (fn [v] {:n v}) "...#peek" (fn [t] (:n t))}}) An import returns an ordinary Clojure value; the host assigns a u32 rep, keeps rep -> [value] for the instance, and mints the handle. A borrow hands the value back. The guest's drop fires the destructor upcall, which removes the entry and calls :drop. A's clustering is the part that needed the second review round. Reflected ITEM_RESOURCE items are grouped by resource_type_equal -- which compares equal between two *reflected* items, the mechanism the earlier drafts twice got wrong -- and each class is registered under every one of its names at one tag, because two names of one type under distinct tags fails instantiate. Naming two keys of one class is refused, since there is one destructor slot. Identity is the rep, not the value: two mints returning the same object are two resources and get two drops. The table stores [value] so a legitimately nil value is distinguishable from a missing rep. Fault injection: not calling :drop turns the suite red in two places, so the assertions are not decorative. One host resource type per instance for now -- the tag is fixed at 1 -- and the note says so. Still proposed: C's AutoCloseable for an own parameter, D's two-walk close, E's readable drop failures. Also fixed on the way: wasmtime refuses a second add_instance for one interface name, so resources and functions now share one linker-instance cache. 122 assertions. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 912d103 commit de65314

3 files changed

Lines changed: 208 additions & 23 deletions

File tree

doc/design/0018-host-defined-resources.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# 0018 — Host-defined resources in `cljwit.host`
22

3-
**Status:** proposed · 2026-07-30 · third version. Two adversarial rounds have
3+
**Status:** A and B implemented (`src/cljwit/host.clj`, one host resource type
4+
per instance); C, D and E still `proposed` · 2026-07-30 · third version. Two adversarial rounds have
45
each falsified something load-bearing; what they found is kept below, because
56
it is most of the evidence.
67

src/cljwit/host.clj

Lines changed: 160 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
(def ^:private ITEM 24) ; wasmtime_component_item_t, kind at 0, union at 8
3939
(def ^:private ITEM-OF 8)
4040
(def ^:private ITEM-FUNC 3) ; WASMTIME_COMPONENT_ITEM_COMPONENT_FUNC
41-
(def ^:private ITEM-IFACE 1) ; ...COMPONENT_INSTANCE — a WIT interface
41+
(def ^:private ITEM-IFACE 1)
42+
(def ^:private ITEM-RESOURCE 4) ; ...COMPONENT_INSTANCE — a WIT interface
4243
(def ^:private VT-OF 8) ; ...its union: one pointer to the specific type
4344
(def ^:private RES-OK 8) ; wasmtime_component_valresult_t {bool; val *}
4445
(def ^:private RES-VAL 16)
@@ -176,7 +177,16 @@
176177
(into-array java.lang.foreign.Linker$Option [])))
177178
:linker-root (b "wasmtime_component_linker_root" ADDR ADDR)
178179
:li-add-inst (b "wasmtime_component_linker_instance_add_instance" ADDR ADDR ADDR I64 ADDR)
179-
:li-add-func (b "wasmtime_component_linker_instance_add_func" ADDR ADDR ADDR I64 ADDR ADDR ADDR)}))
180+
:li-add-func (b "wasmtime_component_linker_instance_add_func" ADDR ADDR ADDR I64 ADDR ADDR ADDR)
181+
:li-add-res (b "wasmtime_component_linker_instance_add_resource" ADDR ADDR ADDR I64 ADDR ADDR ADDR ADDR)
182+
:rty-new-host (b "wasmtime_component_resource_type_new_host" ADDR I32)
183+
:rty-equal (b "wasmtime_component_resource_type_equal" ValueLayout/JAVA_BOOLEAN ADDR ADDR)
184+
:rty-clone (b "wasmtime_component_resource_type_clone" ADDR ADDR)
185+
:hres-new (b "wasmtime_component_resource_host_new" ADDR ValueLayout/JAVA_BOOLEAN I32 I32)
186+
:hres-rep (b "wasmtime_component_resource_host_rep" I32 ADDR)
187+
:hres-delete (b "wasmtime_component_resource_host_delete" nil ADDR)
188+
:hres-to-any (b "wasmtime_component_resource_host_to_any" ADDR ADDR ADDR ADDR)
189+
:any-to-hres (b "wasmtime_component_resource_any_to_host" ADDR ADDR ADDR ADDR)}))
180190

181191
(defn- cstr [^Arena arena ^String t]
182192
(let [b (.getBytes t "UTF-8")
@@ -562,6 +572,14 @@
562572
:variant (mapcat (fn [[_ t]] (unsupported t)) (:cases tree))
563573
[(:kind tree)])))
564574

575+
(definterface ResourceDtor
576+
;; Returns wasmtime_error_t*, not void. Declaring it void makes wasmtime read
577+
;; a garbage register as an error pointer and crash walking it, *after* the
578+
;; destructor has already run correctly (0018).
579+
(^java.lang.foreign.MemorySegment
580+
call [^java.lang.foreign.MemorySegment data ^java.lang.foreign.MemorySegment ctx
581+
^int rep]))
582+
565583
(definterface ImportCallback
566584
(^java.lang.foreign.MemorySegment
567585
call [^java.lang.foreign.MemorySegment data
@@ -584,7 +602,7 @@
584602
[tree]
585603
(cond
586604
(nil? tree) nil
587-
(keyword? tree) (when-not (SCALAR tree) [tree])
605+
(keyword? tree) (when-not (or (SCALAR tree) (#{:own :borrow} tree)) [tree])
588606
:else (case (:kind tree)
589607
:list (unimportable (:element tree))
590608
:tuple (mapcat unimportable (:types tree))
@@ -600,10 +618,36 @@
600618
"One FFM upcall stub for one host import. Lifts the arguments, calls `f`,
601619
lowers the result, and converts anything thrown into a wasmtime error --
602620
a JVM exception unwinding through native frames exits the VM (0017 D)."
603-
[api ^Arena arena sig f nm dead]
604-
(let [lifts (mapv (fn [[_ t]] (lift-fn t nil)) (:params sig))
621+
[api ^Arena arena sig f nm dead rtab]
622+
;; A host resource in an import's signature is one this instance declared,
623+
;; so `:own`/`:borrow` here mean the rep table rather than a guest handle.
624+
;; One host resource type per instance for now — the tag is fixed at 1.
625+
(let [hres-lift (fn [^MemorySegment seg]
626+
(let [any ^MemorySegment (.get seg ADDR (long UNION))
627+
out ^MemorySegment (.allocate arena ^java.lang.foreign.MemoryLayout ADDR)]
628+
(ok! api "any_to_host" (invoke (:any-to-hres api) (:ctx rtab) any out))
629+
(let [hr ^MemorySegment (.get out ADDR (long 0))
630+
rep (invoke (:hres-rep api) hr)]
631+
(invoke (:hres-delete api) hr)
632+
(first (get @(:table rtab) rep)))))
633+
lifts (mapv (fn [[_ t]] (if (contains? #{:own :borrow} t)
634+
hres-lift
635+
(lift-fn t nil)))
636+
(:params sig))
605637
rt (:result sig)
606-
lower (some-> rt lower-fn)
638+
lower (cond
639+
(nil? rt) nil
640+
(= :own rt)
641+
(fn [_ ^MemorySegment seg v]
642+
(let [rep (swap! (:next rtab) inc)
643+
_ (swap! (:table rtab) assoc rep [v])
644+
hr ^MemorySegment (invoke (:hres-new api) true (int rep) (int 1))
645+
out ^MemorySegment (.allocate arena ^java.lang.foreign.MemoryLayout ADDR)]
646+
(ok! api "host_to_any" (invoke (:hres-to-any api) (:ctx rtab) hr out))
647+
(invoke (:hres-delete api) hr)
648+
(.set seg I8 (long 0) (byte RESOURCE))
649+
(.set seg ADDR (long UNION) ^MemorySegment (.get out ADDR (long 0)))))
650+
:else (lower-fn rt))
607651
;; 0017 C: wasmtime frees what the callback writes, so every byte of a
608652
;; result comes from malloc. Measured: an Arena pointer survives one
609653
;; call and aborts the process at 2000.
@@ -873,12 +917,111 @@
873917
cat)
874918
(range (invoke (:import-count api) ct (.-ptr e))))))
875919

920+
(defn- iface-instance
921+
"wasmtime refuses a second `add_instance` for one interface name, so both
922+
resources and functions go through one cache."
923+
[api ^Arena arena root cache iface]
924+
(or (get @cache iface)
925+
(let [out ^MemorySegment (.allocate arena ^java.lang.foreign.MemoryLayout ADDR)]
926+
(ok! api "linker_instance_add_instance"
927+
(invoke (:li-add-inst api) root (cstr arena iface)
928+
(long (count (.getBytes ^String iface "UTF-8"))) out))
929+
(let [v (.get out ADDR (long 0))] (swap! cache assoc iface v) v))))
930+
931+
(defn- reflect-import-resources
932+
"Every `ITEM_RESOURCE` in the import list, clustered by type. One type can
933+
carry several names — a `use`d resource appears once per interface that
934+
uses it, and `type headers = fields` appears again — and reflected items
935+
compare equal when they are the same type (`0018` A)."
936+
[api ^Arena arena ^Engine e ^MemorySegment ct]
937+
(let [[pp lp] (name-pair arena)
938+
item ^MemorySegment (.allocate arena (long ITEM))
939+
found (into []
940+
(comp
941+
(keep (fn [i]
942+
(when (invoke (:import-nth api) ct (.-ptr e) (long i) pp lp item)
943+
(let [nm (read-name pp lp)]
944+
(when (= ITEM-IFACE (bit-and (long (.get item I8 (long 0))) 0xFF))
945+
(let [of (.get item ADDR (long ITEM-OF))
946+
[ip il] (name-pair arena)
947+
sub ^MemorySegment (.allocate arena (long ITEM))]
948+
(into []
949+
(keep (fn [j]
950+
(when (invoke (:iface-nth api) of (.-ptr e) (long j) ip il sub)
951+
(when (= ITEM-RESOURCE (bit-and (long (.get sub I8 (long 0))) 0xFF))
952+
[(str nm "#" (read-name ip il))
953+
(invoke (:rty-clone api) (.get sub ADDR (long ITEM-OF)))]))))
954+
(range (invoke (:iface-count api) of (.-ptr e))))))))))
955+
cat)
956+
(range (invoke (:import-count api) ct (.-ptr e))))]
957+
;; Cluster: each entry is {:names [...] :type ptr}.
958+
(reduce (fn [acc [nm ty]]
959+
(if-let [k (first (keep-indexed
960+
(fn [i cls]
961+
(when (invoke (:rty-equal api) (:type cls) ty) i))
962+
acc))]
963+
(update-in acc [k :names] conj nm)
964+
(conj acc {:names [nm] :type ty})))
965+
[] found)))
966+
967+
(defn- define-resources!
968+
"Registers one host resource type per cluster, under every name in it."
969+
[api ^Arena arena ^Engine e clink ^MemorySegment ct resources table drop-errs cache]
970+
(when (seq resources)
971+
(let [classes (reflect-import-resources api arena e ct)
972+
known (set (mapcat :names classes))
973+
extra (remove known (keys resources))]
974+
(when (seq extra)
975+
(throw (ex-info (str "no such resource: " (pr-str (vec extra)))
976+
{:cljwit/error :no-such-resource
977+
:cljwit/extra (vec extra)
978+
:cljwit/resources known})))
979+
(let [root (invoke (:linker-root api) clink)]
980+
(doseq [[i cls] (map-indexed vector classes)]
981+
(let [named (filterv resources (:names cls))]
982+
(when (< 1 (count named))
983+
(throw (ex-info (str "one type, two keys: " (pr-str named)
984+
" name the same resource, which has one destructor")
985+
{:cljwit/error :duplicate-resource :cljwit/names named})))
986+
(when-let [nm (first named)]
987+
(let [drop-fn (:drop (get resources nm))
988+
tag (int (inc i))
989+
dtor (reify ResourceDtor
990+
(call [_ _d _cx rep]
991+
(try
992+
(let [[v] (get @table rep)]
993+
(swap! table dissoc rep)
994+
(when drop-fn (drop-fn v)))
995+
;; 0018 E: a guest drop must still succeed.
996+
(catch Throwable t (swap! drop-errs conj t)))
997+
MemorySegment/NULL))
998+
stub (.upcallStub (Linker/nativeLinker)
999+
(.bindTo (.findVirtual (MethodHandles/lookup) ResourceDtor "call"
1000+
(MethodType/methodType
1001+
MemorySegment ^"[Ljava.lang.Class;"
1002+
(into-array Class [MemorySegment MemorySegment Integer/TYPE])))
1003+
dtor)
1004+
(FunctionDescriptor/of ADDR (into-array java.lang.foreign.MemoryLayout
1005+
[ADDR ADDR I32]))
1006+
arena (into-array java.lang.foreign.Linker$Option []))
1007+
rty (invoke (:rty-new-host api) tag)]
1008+
;; Every name in the class, one tag: two names under distinct
1009+
;; tags fails instantiate with a message naming neither.
1010+
(doseq [n (:names cls)]
1011+
(let [[iface rname] (str/split n #"#" 2)]
1012+
(ok! api "linker_instance_add_resource"
1013+
(invoke (:li-add-res api) (iface-instance api arena root cache iface)
1014+
(cstr arena rname)
1015+
(long (count (.getBytes ^String rname "UTF-8")))
1016+
rty stub MemorySegment/NULL MemorySegment/NULL))))
1017+
nil))))))))
1018+
8761019
(defn- define-imports!
8771020
"Registers each supplied Clojure function with the linker. Missing imports
8781021
are wasmtime's to report at instantiate, in a message that names both the
8791022
interface and the function (`0017` B); what the host checks is the other
8801023
direction, because a key nobody needs is a typo."
881-
[api ^Arena arena ^Engine e _ctx clink ^MemorySegment ct imports dead]
1024+
[api ^Arena arena ^Engine e _ctx clink ^MemorySegment ct imports dead rtab cache]
8821025
(when (seq imports)
8831026
(let [needed (reflect-imports api arena e ct)
8841027
extra (remove (set (keys needed)) (keys imports))]
@@ -887,8 +1030,7 @@
8871030
{:cljwit/error :no-such-import
8881031
:cljwit/extra (vec extra)
8891032
:cljwit/imports (set (keys needed))})))
890-
(let [root (invoke (:linker-root api) clink)
891-
ifaces (atom {})]
1033+
(let [root (invoke (:linker-root api) clink)]
8921034
(doseq [[nm f] imports]
8931035
(let [sig (get needed nm)
8941036
bad (distinct (mapcat unimportable
@@ -898,17 +1040,8 @@
8981040
{:cljwit/error :unsupported-type
8991041
:cljwit/import nm :cljwit/kinds (vec bad)})))
9001042
(let [[iface fname] (if (str/includes? nm "#") (str/split nm #"#" 2) [nil nm])
901-
li (if (nil? iface)
902-
root
903-
(or (get @ifaces iface)
904-
(let [out ^MemorySegment (.allocate arena ^java.lang.foreign.MemoryLayout ADDR)]
905-
(ok! api "linker_instance_add_instance"
906-
(invoke (:li-add-inst api) root (cstr arena iface)
907-
(long (count (.getBytes ^String iface "UTF-8"))) out))
908-
(let [v (.get out ADDR (long 0))]
909-
(swap! ifaces assoc iface v)
910-
v))))
911-
stub (import-stub api arena sig f nm dead)]
1043+
li (if (nil? iface) root (iface-instance api arena root cache iface))
1044+
stub (import-stub api arena sig f nm dead rtab)]
9121045
(ok! api "linker_instance_add_func"
9131046
(invoke (:li-add-func api) li (cstr arena fname)
9141047
(long (count (.getBytes ^String fname "UTF-8")))
@@ -947,7 +1080,7 @@
9471080
"Instantiates a compiled component. Cheap — tens of microseconds — so a
9481081
store per request is the intended shape."
9491082
(^Instance [^Artifact art] (instantiate art {}))
950-
(^Instance [^Artifact art {:keys [imports wasi]}]
1083+
(^Instance [^Artifact art {:keys [imports wasi resources]}]
9511084
(closed! (.-closed art) "artifact")
9521085
(let [^Engine e (.-engine art)
9531086
_ (closed! (.-closed e) "engine")
@@ -961,7 +1094,12 @@
9611094
;; instance has to know it is dead rather than let every later call
9621095
;; report a trap with the cause gone.
9631096
dead (atom nil)
964-
_ (define-imports! api arena e ctx clink ct imports dead)
1097+
;; 0018 B: the rep table, and the failures a `:drop` collected.
1098+
rtab {:ctx ctx :table (atom {}) :next (atom 0) :errs (atom [])}
1099+
li-cache (atom {})
1100+
_ (define-resources! api arena e clink ct resources
1101+
(:table rtab) (:errs rtab) li-cache)
1102+
_ (define-imports! api arena e ctx clink ct imports dead rtab li-cache)
9651103
_ (when wasi (enable-wasi! api arena ctx clink wasi))
9661104
inst ^MemorySegment (.allocate arena (long INSTANCE))
9671105
_ (ok! api "linker_instantiate"

test/cljwit/host_test.clj

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,49 @@
512512
(is (= 0 ((i "argc")))
513513
"no :args means no arguments, not the host's")))))
514514
(finally (.delete ^File f))))))
515+
516+
(deftest host-defined-resources
517+
;; `0018`. The mechanism is `bb spike-hres`; this is the API over it.
518+
(if-not lib
519+
(println "CLJWIT_WASMTIME_LIB unset — skipping host-resource test")
520+
(let [f (build-component! "hres")]
521+
(try
522+
(with-open [e (host/engine)]
523+
(with-open [a (host/compile e (io/file f))]
524+
525+
(testing "0018 A/B — a resource the host defines, minted and read back"
526+
(let [dropped (atom [])]
527+
(with-open [i (host/instantiate
528+
a {:resources {"local:hres/host@0.1.0#token"
529+
{:drop (fn [v] (swap! dropped conj v))}}
530+
:imports {"local:hres/host@0.1.0#mint"
531+
(fn [v] {:n v})
532+
"local:hres/host@0.1.0#peek"
533+
(fn [t] (:n t))}})]
534+
(is (= 42 ((i "run") 42))
535+
"the guest minted, borrowed and dropped a host resource")
536+
(is (= [{:n 42}] @dropped)
537+
"and :drop saw the value the import returned, not a rep"))))
538+
539+
(testing "a borrow hands over the value, and identity is the rep"
540+
;; Two mints of the same object are two resources, so two drops.
541+
(let [dropped (atom [])
542+
same {:n 1}]
543+
(with-open [i (host/instantiate
544+
a {:resources {"local:hres/host@0.1.0#token"
545+
{:drop (fn [v] (swap! dropped conj v))}}
546+
:imports {"local:hres/host@0.1.0#mint" (fn [_] same)
547+
"local:hres/host@0.1.0#peek" (fn [t] (:n t))}})]
548+
((i "run") 7)
549+
((i "run") 7)
550+
(is (= [same same] @dropped)
551+
":drop is per handle, not per value"))))
552+
553+
(testing "a resource the component does not declare is a typo"
554+
(let [ex (is (thrown? clojure.lang.ExceptionInfo
555+
(host/instantiate
556+
a {:resources {"local:hres/host@0.1.0#nope" {:drop identity}}
557+
:imports {"local:hres/host@0.1.0#mint" (fn [_] nil)
558+
"local:hres/host@0.1.0#peek" (fn [_] 0)}})))]
559+
(is (= :no-such-resource (:cljwit/error (ex-data ex))))))))
560+
(finally (.delete ^File f))))))

0 commit comments

Comments
 (0)