Repository navigation
resources: A throwing [Symbol.dispose] on guest-drop of a host resource poisons the instance but surfaces unbranded (not a Trap) #363
Description
A throwing [Symbol.dispose] on guest-drop of a host resource poisons the instance but surfaces unbranded (not a Trap)
Severity: P3 Confidence: high
Location: runtime/src/embedder/resources.ts:519-524 (HostResourceRegistry.dtor calls dispose bare), runtime/src/cabi/handles.ts:209-216 (callDtorGated, impl === null arm), runtime/src/exec/boundary.ts:1240-1244 (lifted export catch: poison(e); throw e rethrows the raw value)
Authority: contracts/embedder-api.md §"Error model" — "An unbranded throw from a host import is a host bug and traps, with a message naming the import"; "Trap rejections are distinguishable by class"; "A trap escaping a guest activation poisons its instance"; "Recognition is by brand, not class ... isTrap". The import path implements exactly this normalization (instantiate.ts:743-755); platform_class_test.ts limit 3 pins it ("Platform exceptions are unbranded, so they trap").
Expected: a host [Symbol.dispose] that throws during a guest-initiated resource.drop is host code invoked by the guest, like an import body; the export rejects with a Trap (naming the resource) and the instance is poisoned by that trap — or, if the runtime chooses not to treat it as a trap, the instance is not poisoned.
Actual: the raw Error propagates through the trampoline and the wasm frame; the export rejects with the same object (isTrap(e) === false, e === thrown), yet the instance is poisoned and later entry reports "instance poisoned by: Error: dispose failed". Hosts matching by brand (the documented method) cannot tell a poisoning failure from an ordinary error.
Repro: dispose_throws.ts:
control (no throw): {"dropHeld":"ok","isTrap":false,"sameObject":true,"disposed":1,"live":1,"nextCall":"ok"}
plain Error: {"dropHeld":"Error: dispose failed","isTrap":false,"sameObject":true,"disposed":1,"live":1,
"nextCall":"Trap: cannot enter component instance 0 — instance poisoned by: Error: dispose failed"}
Distinct from known issues: not in known-issues.md; #118 is about host return-value conversion failures, not destructor throws.
Fix direction: in callDtorGated's impl === null arm (or in the facade's dtor: (rep) => registry.dtor(rep) closure), wrap a non-Trap, non-ComponentException throw in a Trap naming the resource class, mirroring #wrapImportFn's fail().
Running the repro
Scripts below were run from the repository root at baseline 1a4f5e1 with
the shim and fixtures built (just shim fixtures):
deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
<REPO>/ in the scripts is the absolute path of the checkout (the review ran
them from outside the tree). Each repro was run at least twice and once under a
nonzero POLYENGINE_SCHED_SEED; output was identical across runs.
common.ts
// Shared fixture plumbing for the resources-disposal review track.
const REPO = "<REPO>/";
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import {
type EmbedderOptions,
instantiate,
} from "<REPO>/runtime/src/embedder/mod.ts";
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: EmbedderOptions = {},
// deno-lint-ignore no-explicit-any
): Promise<any> {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const guest = (n: string) => `examples/guests/build/${n}.component.wasm`;
export const emb = (n: string) => `runtime/tests/embedder/${n}.wasm`;
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export const desc = (e: unknown) =>
e === undefined ? "ok" : `${(e as Error)?.name}: ${(e as Error)?.message}`.slice(0, 160);dispose_throws.ts
// Guest drops an own handle of a host-implemented resource whose
// [Symbol.dispose] throws. Which error class does the export see, is the
// instance poisoned, and is the registration removed?
import { caught, desc, emb, fixture } from "./common.ts";
import { INTERNAL_HOST_REGISTRIES } from "<REPO>/runtime/src/embedder/instantiate.ts";
import { isTrap } from "@polyengine/protocol";
class R {
disposed = 0;
constructor(readonly throwOnDispose: unknown = undefined) {}
[Symbol.dispose]() {
this.disposed++;
if (this.throwOnDispose !== undefined) throw this.throwOnDispose;
}
}
async function run(label: string, err: unknown) {
const c = await fixture(emb("resource-overlap"), {
"host:api/res": { R, value: (_r: R) => 7 },
});
const registry = c[INTERNAL_HOST_REGISTRIES].get(0);
const r = new R(err);
const h = await c.exports.hold(r);
const e = await caught(() => c.exports.dropHeld(h));
const e2 = await caught(() => c.exports.hold(new R()));
console.log(label, JSON.stringify({
dropHeld: desc(e),
isTrap: isTrap(e),
sameObject: e === err,
disposed: r.disposed,
live: registry.liveCount,
nextCall: desc(e2),
}));
}
await run("control (no throw):", undefined);
await run("plain Error:", new Error("dispose failed"));
Activity
lannbot commentedon Sep 12, 2026
Cross-check against the pinned Component Model reference (
definitions.py@7c67611) and wasmtime4675ee1. Verdict vocabulary: SPEC-BACKED = the reference mandates the expected behavior; CONTRACT-ONLY = spec silent, polyengine's own contract decides; POLICY-QUESTION = neither decides; WEAKENED = part of the claim is overstated (corrections below).Spec: silent — host-implemented resources are outside the reference model.
canon_resource_dropliftsrt.dtorviainst.store.lift(dtor, ft, opts, rt.impl)and requiresrt.implto be aComponentInstance; a host dtor "throwing" has no representation (aCoreWebAssemblyExceptionfrom a core callee istrap()ed bycall_and_trap_on_throw; a host failure is not modeled). If one maps a host exception to a trap, the store dies.Contract: §"Error model": "An unbranded throw from a host import is a host bug and traps, with a message naming the import"; "
Traprejections are distinguishable by class"; "A trap escaping a guest activation poisons its instance"; "Recognition is by brand". The clause is written for imports; a host[Symbol.dispose]reached from guestresource.dropis host code invoked by the guest and functionally the host's implementation of the dtor import, so the issue's reading is fair, and the observed state (poisoned, but the surfaced object is not aTrap) breaks "Trap rejections are distinguishable by class" regardless of how the dispose is classified. No qualifying clause found;platform_class_test.tslimit 3 pins the import-side normalization. Code confirms:HostResourceRegistry.dtor(resources.ts:519-524) calls dispose bare;callDtorGatedimpl === nullarm (handles.ts:209-216) does not wrap; boundary.ts:1240-1244poison(e); throw erethrows the raw value.Wasmtime: same behavior as polyengine's actual, not its expected. A host-implemented resource's dtor is
Linker::resource(name, ty, dtor: Fn(StoreContextMut, u32) -> Result<()>)(component/linker.rs:836-848), wrapped as an ordinaryHostFunc; itsErrpropagates like any host-function error — the store is marked trapped (func.rs:1476-1479) but the error returned to the embedder is the host's ownanyhow::Error, not awasmtime::Trap(host errors are never re-wrapped;downcast_ref::<Trap>()isNone). RustDropviaResourceTable::deletecannot fail, so theResource<T>-table path has no failure at all. No test pins the error-type question.Verdict: CONTRACT-ONLY — spec silent; the contract's brand-distinguishability and unbranded-host-throw clauses decide it. Note wasmtime's precedent runs the other way (poison + raw host error), so the issue should not cite wasmtime as support.
wasmtime: same behavior (store poisoned, raw host error surfaced unbranded) — which is exactly what polyengine's contract chose to diverge from.
Severity: keep P3 — diagnostic/classification defect; the poisoning itself is correct policy; hosts can still detect via the next entry's
Trap.Notes: The issue's alternative ("or, if the runtime chooses not to treat it as a trap, the instance is not poisoned") should be dropped: the contract already classifies unbranded host throws as traps, and wasmtime poisons too. Fix at the
impl === nullarm (or the facade'sdtorclosure) wrapping intoTrapnaming the resource class is the minimal change consistent with#wrapImportFn'sfail().lannbot commentedon Sep 12, 2026
Contract clause co-suspect. This issue's authority is the contract alone (spec silent on host-implemented resources), and the contract shares authors with the code. The clause in question —
embedder-api.md§"Error model", "an unbranded throw from a host import is a host bug and traps" — is a deliberate polyengine divergence from both wasmtime (a hostErrsurfaces as the rawanyhow::Error, never aTrap) and the draft JS API (any thrown value converts to the WITE). The contract documents and justifies that divergence, so it should probably stand — but the point of this issue is not "match the clause", it is that the runtime applies the clause to imports and not to destructors. Whichever way the clause is adjudicated, the import path (#wrapImportFn'sfail()) and the dtor path (callDtorGated/HostResourceRegistry.dtor) must move together. Labeledpolicyso the divergence is reaffirmed (or not) before the fix lands.lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Promote P3 → P2; removepolicy. A synchronous throwing host destructor still escapes as the exact raw Error (isTrap:false) while poisoning the guest. This is a machine-readable classification defect.The current import-error branding policy is already explicit and deliberately justified in the contract. Extending its consistent application to guest-invoked host destructors does not need to wait for reconsidering that entire policy. Wrap destructor failures with the resource identity and preserve the cause. Do not exempt
ComponentException: a destructor has no WIT error result, so it cannot return that as a guest-visible err. Wasmtime's raw-host-error convention is not an overriding authority for this JS API.