Repository navigation
embedder/instantiate.ts #wrapImportFn: throwing then-getter on host import result skips scope.end()/releaseAsyncArgs cleanup #343
Description
Summary
In #wrapImportFn (runtime/src/embedder/instantiate.ts), a host import
whose returned value has a then implemented as an accessor that throws
skips the argument-cleanup path that every other host-import failure mode
goes through. A pending stream write and a pending future write given to
that import are left parked indefinitely instead of being torn down.
Baseline: 1a4f5e1c6f3fc906ce576acdc4499ef2745d6e41 (main).
Where
runtime/src/embedder/instantiate.ts:757-808 (#wrapImportFn's returned
closure), specifically the isThenable check at
runtime/src/embedder/instantiate.ts:778, whose helper is defined at
runtime/src/embedder/instantiate.ts:1276-1279:
function isThenable(v: unknown): boolean {
return v !== null && typeof v === "object" && "then" in v &&
typeof (v as { then: unknown }).then === "function";
}and the call site:
let out: unknown;
try {
out = dispatch(args);
} catch (e) {
scope.end();
onReject(e); // releaseAsyncArgs(args)
return fail(e);
}
if (isThenable(out)) { // <- getter fires HERE, outside any try/catch
...
}"then" in v is a safe existence check, but the next line,
(v as { then: unknown }).then, is an ordinary property access. If the
host's returned object defines then via a getter, that getter runs after
dispatch(args) has already returned successfully — i.e. outside the
try { out = dispatch(args) } catch block two lines above, which is the only
place in this function that calls scope.end() and onReject(e) (which in
turn calls releaseAsyncArgs(args), runtime/src/embedder/instantiate.ts
releaseAsyncArgs starting around line 1288) for a failing import.
Expected
A throwing then getter is a host bug like any other host-side throw, and
should go through the same teardown as a synchronous throw from the import
body: scope.end() (borrow-wrapper invalidation) and onReject(e) →
releaseAsyncArgs(args) (dropping/cancelling any Stream/Future arguments
passed to the import), with the failure surfaced as a Trap the same way an
unbranded throw from the import body is (see
runtime/src/embedder/instantiate.ts:744-754).
Actual
The getter's throw propagates as an ordinary (unbranded) Error, not a
Trap, and scope.end() / releaseAsyncArgs(args) never run. A Stream
argument's writer, and a Future argument's writer, that were handed to the
import are left parked: no cancellation is ever delivered to them, so a
pending write on either stays unresolved indefinitely (observed: 200ms with
no settlement, versus immediate settlement in the control case below).
Reproduction
Self-contained script, repo-relative paths, run from the repository root
(needs the shim built: translator/translator_shim.wasm, already produced
by just shim):
deno run --config deno.json --allow-read \
--allow-env=POLYENGINE_SCHED_SEED repro-thenable-getter.ts
import { Translator } from "./runtime/src/shim/mod.ts";
import { instantiate } from "./runtime/src/embedder/mod.ts";
import { Stream, Future } from "./runtime/src/embedder/streams.ts";
import { hostFuture } from "./runtime/src/exec/host_streams.ts";
const bytes = await Deno.readFile("./translator/translator_shim.wasm");
const translator = await Translator.create(bytes);
async function fixture(imports: Record<string, unknown>) {
const componentBytes = await Deno.readFile(
"./runtime/tests/embedder/host-settlement.wasm",
);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, {});
}
class R {
disposed = 0;
[Symbol.dispose]() {
this.disposed++;
}
}
const turn = () => new Promise<void>((r) => setTimeout(r, 0));
async function run(label: string, consume: () => unknown) {
const c = await fixture({
r: R,
make: () => new R(),
makeSync: () => new R(),
producers: { sources: () => { throw new Error("unused"); } },
consume,
});
const { stream, writer } = Stream.create<number>();
const fw = hostFuture<number>({ kind: "u32" });
const future = Future.fromHostFuture(fw, {
element: { kind: "u32" },
toHost: (v: unknown) => v as number,
fromHost: (v: number) => v,
});
const written = writer.writeAll(new Uint8Array([1, 2]));
let callSettled = "pending";
let callError: unknown;
const callPromise = Promise.resolve(c.exports.consume(stream, future, 0))
.then(
() => callSettled = "resolved",
(e: unknown) => { callSettled = "rejected"; callError = e; },
);
await Promise.race([callPromise, new Promise((r) => setTimeout(r, 200))]);
await turn();
await turn();
let writtenSettled = "pending";
written.then(
(n: number) => writtenSettled = "resolved:" + n,
(e: unknown) => writtenSettled = "rejected:" + e,
);
await turn();
const futureWriteOutcome = await Promise.race([
fw.write(7).then(() => "accepted").catch((e: unknown) => "rejected:" + e),
new Promise<string>((r) => setTimeout(() => r("TIMED_OUT_200ms"), 200)),
]);
console.log(JSON.stringify({
label, callSettled,
callError: callError === undefined ? undefined : String(callError),
writtenSettled, futureWriteOutcome,
}));
}
// Control: an ordinary throw from the import body. Caught by the try/catch
// around dispatch(args); scope.end()/releaseAsyncArgs(args) run normally.
await run("throw-in-body (control)", () => {
throw new Error("thrown from import body");
});
// Candidate: the import returns an object whose `then` is an accessor that
// throws. isThenable's property read happens after dispatch(args) already
// returned, outside any try/catch.
await run("throwing-then-getter", () => ({
get then() { throw new Error("thrown from then getter"); },
}));
Deno.exit(0);Observed output
{"label":"throw-in-body (control)","callSettled":"rejected",
"callError":"Trap: import 'consume' threw Error: thrown from import body. An unbranded throw from a host import is a host bug and becomes a trap: signal a WIT error with `throw new ComponentException(payload)`.",
"writtenSettled":"resolved:0","futureWriteOutcome":"accepted"}
{"label":"throwing-then-getter","callSettled":"rejected",
"callError":"Error: thrown from then getter",
"writtenSettled":"pending","futureWriteOutcome":"TIMED_OUT_200ms"}
The control case shows the intended shape: the call rejects as a Trap, the
pending stream write settles immediately (writtenSettled: "resolved:0",
i.e. torn down), and the future argument is released promptly
(futureWriteOutcome: "accepted").
The candidate case shows the bug: the call still rejects, but with an
unbranded Error rather than a Trap (a second, smaller inconsistency), and
— the substantive part — the stream write never settles
(writtenSettled: "pending" after two full turns) and the future write times
out at 200ms with no cancellation ever reaching it
(futureWriteOutcome: "TIMED_OUT_200ms"), because neither scope.end() nor
releaseAsyncArgs(args) ran for this call.
Suggested fix shape
Wrap the isThenable(out) check (or the isThenable helper's property
access) in the same try/catch that already exists two lines above it in
#wrapImportFn, routing a throwing accessor through the identical
scope.end() / onReject(e) / fail(e) path a throwing import body
already gets.
Scope note
This report is limited to the skipped borrow-scope/argument-cleanup
behavior demonstrated above. It does not claim anything about the calling
task's own lifecycle (the call itself settles promptly, as shown) beyond
what's reproduced here.
Activity
lannbot commentedon Sep 12, 2026
Triage: P2 / bug — third in this batch
Source-confirmed on
mainat1a4f5e1: theisThenable(out)property access is outside the dispatch catch, so a throwing accessor bypasses bothscope.end()andonReject. The report's component-level repro was not independently rerun during this triage.The stranded stream/future operations make this more than a diagnostic inconsistency. P2 reflects the unusual trigger—a broken host return object with a throwing
thenaccessor—rather than dismissing the cleanup failure. Prioritize ahead of the other P2s because it strands arguments and borrow scope across the general import boundary.Acceptance: throwing thenable inspection follows the established host-throw cleanup and error path; pending stream/future operations settle and retained borrow wrappers are invalidated. Cover a throwing Proxy inspection as well: the report's claim that
"then" in vis safe does not hold for a Proxy with a throwinghastrap. Preserve future-typed returns as future sources rather than adopting them as call completion.The cleanup fix does not depend on #118's broader decision about malformed return-value error policy. Keep the scope on failed thenable inspection and argument/scope retirement.
Batch order: #342 → #345 → #343 → #346 → #347 → #344; no hard dependency on the other five.
lannbot commentedon Sep 12, 2026
Component Model / Wasmtime verification
P2 unchanged; JS import-wrapper cleanup is the decisive authority.
Checked Component Model
7c676115and Wasmtime4675ee16(full pins in the repository). The reference models host calls throughFuncInst(on_start, on_resolve) → on_cancel, not JavaScript thenable inspection.Subtask.deliver_resolvereleases lenders on terminal delivery, but the reference does not specify what a throwing JSthenaccessor does or automatically dispose host-owned stream/future argument wrappers.Wasmtime's
HostResultdistinguishes a typedResult<T>from a Rust Future structurally; no accessor is invoked to decide which it is. Normal host-result lowering validates scope exit, and cancelled async results skiplower_rawthrough anOptiongate (same file, lines 469–500). This is not a test of the JS exception-cleanup path.Do not infer automatic resource cleanup from Rust RAII: native
StreamReaderandFutureReaderexplicitly requireclose/pipeor a guarded wrapper; their documentation warns that dropping an unguarded wrapper can leak and hang its peer (futures_and_streams.rs, lines 1253–1258 and 1652–1657).The skipped
scope.end()/releaseAsyncArgsremains a confirmed local bug under polyengine's stronger contract. Nospec-divergencelabel is warranted for the JS-specific trigger.lannbot commentedon Sep 13, 2026
Reverified against current main
2a98815after #344/#352 landed. The original component-level repro still reproduces: ordinary import-body throw becomes Trap and settles the pending stream writer with 0; throwing then getter escapes as raw Error, leaves the stream writer pending, and the future write remains pending at the 200 ms observation bound. Added a throwing Proxyhastrap control: it produces the same skipped-cleanup result. Thus both property existence testing and property access need to be inside the host-dispatch failure boundary. Source locations are now instantiate.ts:778–785 and isThenable:1283–1285. Recommended narrow fix: evaluate isThenable in the existing dispatch try/catch, retaining normal-result conversion outside it so this does not accidentally change #118/error policy or the future-source branch. Add actual argument retirement and borrow-wrapper invalidation regressions, not just error-class assertions. No product changes made in this verification; P2 unchanged.