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

Commit 15191d6

Browse files
committed
0018: defer the host-resource API until 0017 A's linker question is settled
The first draft proposed an API. A review ran every falsifier and the fact the note called settled is false: resource item token == resource_type_new_host(7)? 0 func mint result own == resource_type_new_host(7)? 0 Nothing reflected from a component ever compares equal to a host-registered type, so the discriminator the design rested on does not exist at reflection time. Verified here before folding in. What does compare equal is the component's own import items, which maps names to types rather than types to tags -- a different mechanism -- and at runtime resource_any_type does return the tag, but 0014 A builds marshallers once from the reflected type. Three more, each a hole the draft had no rule for: An own parameter leaks. When a guest hands a host resource back there is no guest drop, ever -- dtor calls=0 with the entry outstanding -- and the header says why: "host resources do not have a 'drop' operation. It's up to the host to define what it means to drop an owned resource." For wasi:io-shaped interfaces that is an OS handle per transfer. store_delete runs no destructors, so the draft's "the table dies with the instance" drops every surviving value on the floor. Close must run :drop, which is the 0016 D rule the draft cited without copying. A `use`d resource is one type under many names, and one name can denote two types. wasi:io/streams uses pollable; filesystem, sockets and http all use the stream types. So "keyed by the exact WIT name" does not determine which key the user writes. Two falsifiers deleted because they do not fire: a borrow held across calls resolves correctly, and distinct host tags are distinct. The structural point is why this is deferred rather than rewritten. Two of the draft's decisions turn on the linker being per-instantiate, and 0017 A already argues it should become engine-scoped because add_resource costs an upcall stub per resource type per instantiate -- 7 us warm against a 20 us instantiate. Writing the API now means writing it against a shape already argued to change. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 1727fbe commit 15191d6

1 file changed

Lines changed: 119 additions & 96 deletions

File tree

Lines changed: 119 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,136 @@
11
# 0018 — Host-defined resources in `cljwit.host`
22

3-
**Status:** proposed · 2026-07-30 · the mechanism is `bb spike-hres`; this is
4-
the API over it, and no code exists
3+
**Status:** deferred · 2026-07-30 · the first draft proposed an API; an
4+
adversarial review falsified the fact it called settled and showed two of its
5+
decisions turn on a question `0017` A has already argued should be reopened.
6+
What the review established is recorded here, because it is most of the work.
57

68
## The question
79

810
`0017` A keys imports by `iface#func`. A **resource is a type, not a
911
function**, so that map has nowhere to put one — and every `wasi:io` interface
10-
declares one, so this is not an edge case.
11-
12-
What is settled, measured 2026-07-30:
13-
14-
- **The round trip works** (`bb spike-hres`): a guest mints a host resource,
15-
reads it through a `borrow`, and drops it; the host's destructor fires with
16-
the right rep.
17-
- **A host resource is a `u32` type tag plus a destructor upcall**, and the
18-
destructor returns `wasmtime_error_t *`, not `void`. Getting that wrong
19-
crashes the JVM *after* the callback has run correctly.
20-
- **A handle carries only a `u32` rep.** `resource_host_new(owned, rep, ty)`
21-
mints one; `any_to_host` + `host_rep` reads it back. There is nowhere to put
22-
a Clojure value, so the host must keep the mapping.
23-
- **The marshaller can tell a host resource from a guest one by type.** A
24-
valtype's union carries `own`/`borrow` → `resource_type_t *`, and
25-
`resource_type_equal` compares it against the types the host registered.
12+
declares one.
13+
14+
`bb spike-hres` proves the mechanism: a guest mints a host resource, reads it
15+
through a `borrow`, and drops it, and the destructor fires with the right rep.
16+
The API over it is what is deferred.
2617

2718
## The decision
2819

29-
### A. Resources are declared in their own map, keyed by the same WIT name
20+
**Do not design this yet. Settle whether the linker is per-instantiate or
21+
engine-scoped first** (`0017` A), because it determines the rep table's scope,
22+
how a destructor knows which instance it is dropping for, and whether a store
23+
data pointer is needed. Writing the API now means writing it against a shape
24+
that has already been argued should change.
25+
26+
`wasmtime_component_linker_instance_add_resource` costs one upcall stub per
27+
resource type per instantiate — 7 µs warm, 30–160 µs cold against `0014` C's
28+
20 µs instantiate — so a handful of host resource types roughly triples
29+
instantiation in the per-request shape `0014` C is built around. That is the
30+
same question, not a separate one.
31+
32+
## What the review established, and what it cost the first draft
3033

31-
```clojure
32-
(host/instantiate
33-
art {:resources {"local:hres/host@0.1.0#token" {:drop (fn [v] (close! v))}}
34-
:imports {"local:hres/host@0.1.0#mint" (fn [v] (open-thing v))
35-
"local:hres/host@0.1.0#peek" (fn [t] (:n t))}})
34+
### The reflection-time discriminator does not exist
35+
36+
The draft called this settled: *"a valtype's union carries `own`/`borrow` →
37+
`resource_type_t *`, and `resource_type_equal` compares it against the types
38+
the host registered."* Measured, on this repo's own `hres` component:
39+
40+
```
41+
resource item token == resource_type_new_host(7)? 0
42+
func mint result own == resource_type_new_host(7)? 0
3643
```
3744

38-
The identity stays the exact WIT name (`0014` B, `0017` A). What changes is
39-
that a resource is a different *kind* of thing, so it gets a different key in
40-
the options map rather than a different value shape under the same one.
41-
42-
### B. A host resource is any Clojure value; the host owns the rep table
43-
44-
The import that returns one returns an ordinary value. `cljwit.host` assigns it
45-
a `u32` rep, keeps `rep → value` for the instance, and mints the handle. A
46-
`borrow` parameter of that type arrives as the value, not as a rep or a handle.
47-
48-
The guest's drop removes the entry and calls `:drop` with the value, if given.
49-
50-
### C. The table is per-instance and dies with it
51-
52-
Reps are only meaningful inside one store. The table lives beside the handle
53-
registry `0016` D already keeps, and `Instance/close` clears it — after
54-
closing outstanding guest handles, before deleting the store.
55-
56-
### D. `:drop` may not throw
57-
58-
It runs inside wasmtime's destructor trampoline, which is the same place `0017`
59-
D's rule applies: a JVM exception unwinding through native frames exits the VM.
60-
Anything thrown is caught and converted, and the instance is marked dead, as an
61-
import's exception already is.
62-
63-
## Why
64-
65-
**A, because a resource is not a function and pretending otherwise costs
66-
later.** `0017` F recorded the shape; the alternative — one map whose values
67-
are sometimes functions and sometimes maps — makes a typo in the value shape
68-
into a silent misregistration.
69-
70-
**B, because the rep is a `u32` and nothing else fits through it.** Any design
71-
where the Clojure value crosses the boundary is impossible; the only question
72-
is who keeps the table, and the host is the only party that sees both sides.
73-
74-
**D, because the failure is the worst one available**, and it is the same
75-
failure `0017` D already pays a `try` to prevent.
76-
77-
## Alternatives rejected
78-
79-
- **One `:imports` map with a value shape that says "resource".** See A.
80-
- **Exposing the rep to Clojure.** Smallest, and it makes a capability an
81-
integer anyone can forge — the same argument `0016` A made against integer
82-
handles for guest resources.
83-
- **A global rep table.** Reps are store-scoped; a global one would collide
84-
between instances and leak.
85-
- **Letting `:drop` propagate.** It cannot: the frame it would unwind through
86-
is native.
87-
- **Requiring the user to mint handles explicitly**, with a `host/resource`
88-
constructor called inside the import. Honest about what happens, and it
89-
makes every `wasi:io`-shaped import carry ceremony that the type already
90-
determines.
91-
92-
## What would falsify this
93-
94-
- **A resource type used by two instances of the same artifact.** The type is
95-
registered on the *linker*, which `0014` C creates per instantiate, so each
96-
instance has its own — unverified, and if linkers turn out shareable the
97-
table's scope is wrong.
98-
- **`borrow` parameters of a host type arriving as something other than a
99-
handle to a live rep** — the spike read one, but only inside the call that
100-
minted it. A borrow held across two guest calls is untested.
101-
- **The type comparison being too coarse.** `resource_type_equal` is the only
102-
discriminator; if two distinct host types compare equal, values would be
103-
handed to the wrong `:drop`.
104-
- **`own` parameters** — a guest handing a host resource *back*. The spike only
105-
covers the guest receiving one.
106-
- **The cost.** A rep table lookup per borrow and a `swap!` per mint, on a call
107-
that `0013` prices at ~0.4 µs. Unmeasured.
45+
**Nothing reflected from a component ever compares equal to a host-registered
46+
type.** What does compare equal is the component's own import items — a
47+
`mint` result matches the interface's `token` item — which is a different
48+
mechanism, and it maps names to types rather than types to tags. At *runtime*,
49+
`resource_any_type(any)` does return the registered tag, but `0014` A builds
50+
marshallers once from the reflected type; a per-value type comparison is a
51+
different design with a per-value allocate/compare/delete cost.
52+
53+
Two traps in the same area, both measured:
54+
55+
- **Type identity is per-component-load, and the pointers are reused.**
56+
Distinct types printed the same `resource_type_t *` address while comparing
57+
unequal, and a second load of the same file produced a type unequal to the
58+
first load's. Anything keying a map by the pointer maps every host resource
59+
to whichever it read last.
60+
- **A `use`d resource is one type under many names.** In a component where one
61+
interface `use`s another's resource, both names denote one type — and the
62+
same name can denote *two* types (the imported one and the exported one).
63+
`wasi:io/streams` `use`s `pollable` from `wasi:io/poll`, and
64+
`wasi:filesystem`, `wasi:sockets` and `wasi:http` all `use` the stream types,
65+
so keying `:resources` by "the exact WIT name" does not determine which key
66+
the user must write.
67+
68+
### An `own` parameter leaks, and the header says why
69+
70+
The draft's only removal rule was "the guest's drop removes the entry". When a
71+
guest hands a host resource *back* — `eat: func(t: token) -> u32` — there is no
72+
guest drop, ever. Measured: `dtor calls=0, table entries=1` after the call.
73+
74+
`wasmtime/component/val.h`: *"unlike `wasmtime_component_resource_any_t` host
75+
resources do not have a 'drop' operation. It's up to the host to define what it
76+
means to drop an owned resource and handle that appropriately."*
77+
78+
So for `wasi:io`-shaped interfaces — where the value is a file or a socket —
79+
the draft would leak one OS handle per transfer. And it made the leak
80+
undiagnosable: the Clojure function was to receive "the value" for both
81+
`borrow` and `own`, so it could not tell it had just been handed ownership.
82+
The information is there and static: `own` is valtype kind 21, `borrow` 22.
83+
84+
### `store_delete` runs no destructors
85+
86+
The draft said the table "dies with" the instance. Measured: deleting the store
87+
leaves `dtor calls=0` with an entry outstanding. So every host resource the
88+
guest still holds at teardown — the normal case for a component that trapped —
89+
would be dropped on the floor. `Instance/close` must **run** `:drop` over the
90+
survivors, which is the mirror of `0016` D that the draft cited without
91+
copying.
92+
93+
### D survives, and its alternatives list was wrong
94+
95+
A destructor returning an error does reach the caller with its message and does
96+
poison the store, exactly as `0017` D found for imports — so "mark the instance
97+
dead" is right. But the draft's only rejected alternative was "letting `:drop`
98+
propagate", which is impossible and therefore not an alternative. The real
99+
choice is between converting to a wasmtime error — which destroys an otherwise
100+
healthy instance because one `close!` failed — and catching, recording on the
101+
instance, and returning `NULL`, so the guest's drop succeeds and the failure
102+
surfaces where a user can see it.
103+
104+
### Two falsifiers deleted, because they do not fire
105+
106+
- **A `borrow` held across two guest calls works.** The guest stashed a handle
107+
and borrowed it on later calls; the rep resolved both times and the eventual
108+
drop fired correctly.
109+
- **Distinct host types are distinct.** `resource_type_equal(host(7), host(8))`
110+
is 0, and a guest passing the wrong resource never reaches the host — wasmtime
111+
refuses with `handle index used with the wrong type`. (Its message says
112+
"guest-defined" for two host-defined resources, which is worth remembering
113+
next to `0016`'s lesson about trusting error text.)
114+
115+
### Value-as-identity is not injective
116+
117+
The draft made the Clojure value the resource. Two `mint` calls returning the
118+
same object — a cached connection, an interned keyword, `nil` — get two reps
119+
and one value, and two guest drops call `:drop` twice on it. `0016` A's own
120+
argument against integer handles applies here and the draft did not answer it.
121+
122+
## What the next attempt has to decide
123+
124+
1. Linker scope, first (`0017` A) — it fixes the table's scope and the
125+
destructor's routing.
126+
2. Removal on `own` transfer, since the guest will never drop it.
127+
3. Running `:drop` at instance close rather than clearing.
128+
4. How a resource type is named, given that `use` makes names many-to-one and
129+
one name ambiguous.
130+
5. Whether identity is the value or a host-minted token.
108131

109132
## Resources
110133

111134
- `bb spike-hres` and `dev/resources/hres.{wit,wat}` — the mechanism.
112-
- `0017` — the import API this extends, and the exception rule D inherits.
113-
- `0016` — the guest-resource side, and the argument against integer handles.
135+
- `0017` A — the linker question this waits on.
136+
- `0016` D — the close rule this has to mirror.

0 commit comments

Comments
 (0)