Repository navigation
embedder: export-side stream/future arguments are never torn down when the call fails before guest entry #355
Description
export-side stream/future arguments are never torn down when the call fails before guest entry
Severity: P2 Confidence: high
Location: runtime/src/embedder/instantiate.ts:1052-1059, 1073-1081 (#wrapExportFn: release() only), :1155-1163, 1177-1185 (#buildSyncForm, same), runtime/src/embedder/values.ts:599-604 (fromHost → lowerStreamSource/lowerFutureSource), runtime/src/embedder/streams.ts:981-1001 (lowerStreamSource locks the reader and starts pump at lowering time). releaseAsyncArgs (instantiate.ts:1287-1298) exists only on the import side.
Authority: embedder-api.md "Streams and futures" — "The runtime facade owns pumping and closes activity retention on end/drop."; "Producer cleanup follows consumption. Whenever the pump permanently stops consuming an unfinished ReadableStream … it calls reader.cancel() before releasing the reader lock." "Error model" — poisoned-instance refusal "occurs before entering".
Expected: A call that fails after lowering but before the guest receives the handles (later-parameter TypeError, poisoned-instance refusal, arity/SyncEntryBusy refusals ordered after lowering) must release the lowered ends: cancel the reader and unlock the source, drop the orphan writer.
Actual: The pump keeps the ReadableStream locked and parks on writeAll against a readable end nobody holds, forever. The embedder cannot reuse or cancel its source.
Repro: lower_params_stream_leak.ts (fixture host-settlement.wasm, consume(rs, promise, "junk") and consume(rs, promise, 0) after trap()); both modes:
control consume(rs, p, 0) [host import drops both]: resolved:2 {"locked":false,"cancelled":1,…}
consume(rs, p, "junk") threw: TypeError: consume: u32 expects an integer number
source after failure: {"locked":true,"cancelled":0,…} rs.getReader() -> TypeError: ReadableStream is locked
consume on poisoned instance threw: Trap: cannot enter component instance 0 — instance poisoned by: …
source after refusal: {"locked":true,"cancelled":0,…}
Note (checked, orphan_arm_hang.ts): the orphan pump is not bound to the store, so it does not suppress deadlock verdicts — block() still traps. The harm is the leaked, permanently locked source.
Distinct from known issues: #343 is the import side (then getter skips releaseAsyncArgs); here the export side has no async-arg cleanup at all, on every pre-entry failure.
Fix direction: in #wrapExportFn/#buildSyncForm, on any throw before/at fn(...lowered), run the export-side equivalent of releaseAsyncArgs over the lowered values (drop the host ends), and in #lowerParams release lowered stream/future values of the prefix when a later param fails.
Also observed independently (embedder-handles track; adds the deferred-Future refusal case D and the import-result record case E)
Severity: P2 Confidence: high
Location: runtime/src/embedder/instantiate.ts:996-1020 (#lowerParams — scope collects only borrow releases, :434-448; the catch at 1013 releases nothing else); runtime/src/embedder/values.ts:599-604 (fromHost → lowerStreamSource/lowerFutureSource); runtime/src/embedder/streams.ts:242-269 (takeValue sets #consumed = true at 267 before the guest is ever entered), :996-1000 (pump started at lower time)
Authority: embedder-api.md §"Streams and futures" — "a still-deferred handle is refused loudly"; "a stream value may only be transferred once" (the runtime's own takeValue wording — but here no transfer happened); "Producer cleanup follows consumption. Whenever the pump permanently stops consuming an unfinished ReadableStream … it calls reader.cancel() before releasing the reader lock"; "Untaken elements are destroyed". §"Error model": a conversion TypeError is a host-side failure of the call, not a state transition on the arguments.
Expected: when a later parameter fails conversion (or a deferred Future is refused), earlier stream/future arguments are restored/torn down: a Stream handle is still readable/transferable, a ReadableStream source is cancelled and unlocked, a Promise source is forgotten, lowered own elements are released.
Actual (p1_partial_lower.ts, host-settlement consume(s, f, mode)):
A call error: TypeError: consume: u32 expects an integer number
A read after failed call: TypeError: this Stream handle has already been passed to a guest; ...
A writer.write after failed call: TIMEOUT (parked forever, no reader will ever exist)
A retry passing same handle: TypeError: ... may only be transferred once
B source: {"locked":true,"cancelled":0} (ReadableStream reader lock held forever, cancel() never called)
D call error: TypeError: this Future is still in flight and cannot be passed to a guest yet
D read on stream after refusal: TypeError: this Stream handle has already been passed to a guest; ...
For an array source of own<R> elements the same path lowers them inside the orphaned pump (packChunk → bridge.lowerOwn) and parks in writeAll with no drop path ever reachable — a leak of host-owned resources (by code reading: streams.ts:1053-1057; no fixture with (stream<own<R>>, failing-param) exists to demonstrate). The same class applies to import-result records (p1 case E: producers.sources() returning {stream, future: deferred} leaves the ReadableStream {"locked":true,"cancelled":0}); note the conversion TypeError in that case vanished entirely (no rejection, store.hostFailure undefined) — that half is instantiate.ts ok(out) at :806 outside fail(), adjacent to #343/#118 and outside this track.
Repro: p1_partial_lower.ts
Distinct from known issues: #343 is import-side cleanup skipped by a throwing then getter; this is export-side parameter lowering with no cleanup mechanism at all for async values (only borrows are scoped).
Fix direction: have fromHost register a teardown for each Stream/Future/pump it lowers into the active #lowerScope (mirroring releaseAsyncArgs), run on the #lowerParams failure path; for a Stream handle, defer #consumed = true until the call is actually entered, or un-consume on failure.
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.
support.ts
// Shared fixture plumbing for the export-drivers adversarial track.
// Mirrors runtime/tests/embedder/support.ts but with absolute repo paths.
export 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 artifactsOf(rel: string) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return { plan, componentBytes, adapters };
}
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: EmbedderOptions = {},
// deno-lint-ignore no-explicit-any
): Promise<any> {
return await instantiate(await artifactsOf(rel), imports, opts);
}
export const guest = (n: string) => `examples/guests/build/${n}.component.wasm`;
export const embed = (n: string) => `runtime/tests/embedder/${n}.wasm`;
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function raceTimeout<T>(p: Promise<T>, ms: number): Promise<T | "TIMEOUT"> {
return Promise.race([
p,
new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
]);
}
export const show = (e: unknown) =>
e instanceof Error ? `${e.name}: ${e.message.split("\n")[0].slice(0, 160)}` : String(e);lower_params_stream_leak.ts
// F-candidate: #wrapExportFn / #lowerParams never tear down stream/future
// arguments when the call fails before entering the guest: (a) a later
// parameter fails to lower; (b) the instance is poisoned (entry refusal).
// The ReadableStream stays locked with a pump parked forever; the Promise
// future source is adopted by an orphan writer.
import { caught, embed, fixture, raceTimeout, show, turn } from "./support.ts";
class R {
[Symbol.dispose]() {}
}
async function make(jspi: boolean) {
return await fixture(embed("host-settlement"), {
r: R,
make: () => new R(),
makeSync: () => new R(),
producers: { sources: () => { throw new Error("unused"); } },
consume: (s: { drop(): void }, f: { drop(): void }) => { s.drop(); f.drop(); return 0; },
}, { jspi });
}
function src() {
let cancelled = 0;
let pulls = 0;
const rs = new ReadableStream<Uint8Array>({
pull(ctl) {
pulls++;
ctl.enqueue(new Uint8Array([1, 2, 3]));
},
cancel() {
cancelled++;
},
});
return { rs, state: () => ({ locked: rs.locked, cancelled, pulls }) };
}
for (const jspi of [false, true]) {
const tag = jspi ? "jspi" : "plain";
{
// Control: a valid call — the guest consumes; stream is used, then released.
const c = await make(jspi);
const { rs, state } = src();
const r = await raceTimeout(
Promise.resolve(c.exports.consume(rs, Promise.resolve(7), 0)).then(
(v) => "resolved:" + v,
(e) => "rejected:" + show(e),
),
500,
);
await turn(); await turn();
console.log(`[${tag}] control consume(rs, p, 0) [host import drops both]:`, r, JSON.stringify(state()));
}
{
// (a) later-parameter lowering failure after the stream/future were lowered.
const c = await make(jspi);
const { rs, state } = src();
const e = await caught(() => c.exports.consume(rs, Promise.resolve(7), "junk"));
await turn();
await turn();
console.log(`[${tag}] consume(rs, p, "junk") threw:`, show(e));
console.log(`[${tag}] source after failure:`, JSON.stringify(state()), "(expected locked:false or cancelled:1)");
const reuse = await caught(() => rs.getReader());
console.log(`[${tag}] rs.getReader() ->`, reuse === undefined ? "ok" : show(reuse));
}
{
// (b) entry refusal on a poisoned instance, after the args were lowered.
const c = await make(jspi);
const t = await caught(() => c.exports.trap());
console.log(`[${tag}] trap() ->`, show(t));
const { rs, state } = src();
const e = await caught(() => c.exports.consume(rs, Promise.resolve(7), 0));
await turn();
await turn();
console.log(`[${tag}] consume on poisoned instance threw:`, show(e));
console.log(`[${tag}] source after refusal:`, JSON.stringify(state()), "(expected locked:false or cancelled:1)");
}
}h.ts
// Shared harness for the embedder-handles adversarial track.
// Run from the repo root:
// deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
const REPO = "<REPO>/";
export const root = REPO;
export const { instantiate } = await import(
REPO + "runtime/src/embedder/mod.ts"
);
export const { Translator } = await import(REPO + "runtime/src/shim/mod.ts");
export const streamsMod = await import(REPO + "runtime/src/embedder/streams.ts");
export const hostStreamsMod = await import(
REPO + "runtime/src/exec/host_streams.ts"
);
export const taskMod = await import(REPO + "runtime/src/task/mod.ts");
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: Record<string, unknown> = {},
) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const emb = (name: string) => `runtime/tests/embedder/${name}.wasm`;
export const guest = (name: string) =>
`examples/guests/build/${name}.component.wasm`;
export const unhandled: string[] = [];
export const unhandledStacks: string[] = [];
globalThis.addEventListener("unhandledrejection", (ev) => {
ev.preventDefault();
const reason = (ev as PromiseRejectionEvent).reason;
unhandled.push(String(reason));
unhandledStacks.push(
reason instanceof Error ? (reason.stack ?? "") : String(reason),
);
});
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function timeout<T>(p: Promise<T>, ms = 200): Promise<T | "TIMEOUT"> {
return Promise.race([
p,
new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
]);
}
export function show(label: string, v: unknown) {
console.log(label + ":", typeof v === "string" ? v : JSON.stringify(v));
}
export const errStr = (e: unknown) =>
e instanceof Error ? `${e.name}: ${e.message}` : String(e);p1_partial_lower.ts
// Probe 1: partial parameter lowering failure on an export call.
// A stream/future arg lowered BEFORE a later arg fails conversion. What
// happens to the stream handle / pump / source?
import { caught, emb, errStr, fixture, show, timeout, turn, unhandled, streamsMod } from "./h.ts";
const { Stream } = streamsMod;
class R {
disposed = 0;
[Symbol.dispose]() {
this.disposed++;
}
}
async function make() {
return await fixture(emb("host-settlement"), {
r: R,
make: () => new R(),
makeSync: () => new R(),
producers: { sources: () => { throw new Error("unused"); } },
consume: () => 0,
}, {});
}
// --- Case A: Stream handle (created) lowered, then `mode: u32` fails.
{
const c = await make();
const { stream, writer } = Stream.create<number>();
const e = await caught(() => c.exports.consume(stream, Promise.resolve(1), "bad"));
show("A call error", errStr(e));
// The handle was never delivered to a guest. Can the host still read it?
const r = await caught(() => stream.read(1));
show("A read after failed call", errStr(r));
const w = timeout(writer.write([1, 2, 3]));
show("A writer.write after failed call", await w);
const pass = await caught(() => c.exports.consume(stream, Promise.resolve(1), 0));
show("A retry passing same handle", errStr(pass));
}
// --- Case B: ReadableStream source lowered (pump started, reader locked), then mode fails.
{
const c = await make();
let cancelled = 0;
const src = new ReadableStream<number[]>({
start(ctl) {
ctl.enqueue([1, 2]);
},
cancel() {
cancelled++;
},
});
const e = await caught(() => c.exports.consume(src, Promise.resolve(1), "bad"));
show("B call error", errStr(e));
await turn();
await turn();
show("B source", { locked: src.locked, cancelled });
}
// --- Case C: Promise future source lowered, then mode fails; promise later rejects.
{
const c = await make();
let rej!: (e: unknown) => void;
const p = new Promise<number>((_, r) => rej = r);
const e = await caught(() => c.exports.consume([1], p, "bad"));
show("C call error", errStr(e));
rej(new Error("late producer failure"));
await turn();
await turn();
const ping = await caught(() => c.exports.ping());
show("C ping after orphaned future rejected", errStr(ping) || "ok");
}
// --- Case D: Stream handle then a still-deferred Future (refused loudly).
{
const c = await fixture(emb("future-disposal"), {}, {});
const c2 = await make();
const deferred = c.exports.run(); // Future<u32>, deferred until the export completes
const { stream } = Stream.create<number>();
const e = await caught(() => c2.exports.consume(stream, deferred, 0));
show("D call error", errStr(e));
const r = await caught(() => stream.read(1));
show("D read on stream after refusal", errStr(r));
deferred.drop();
}
await turn();
show("unhandled", unhandled);
// --- Case E: import RESULT lowering — record { stream, future } where the
// future member is refused after the stream member's pump started.
{
const fd = await fixture(emb("future-disposal"), {}, {});
const deferred = fd.exports.run();
let cancelled = 0;
const src = new ReadableStream<number[]>({
start(ctl) { ctl.enqueue([1]); },
cancel() { cancelled++; },
});
const c = await fixture(emb("host-settlement"), {
r: R,
make: () => new R(),
makeSync: () => new R(),
producers: { sources: () => ({ stream: src, future: deferred }) },
consume: () => 0,
}, {});
const e = await caught(() => c.exports.sources(0));
show("E sources() call error", errStr(e));
await turn(); await turn();
show("E source", { locked: src.locked, cancelled });
show("E ping after", errStr(await caught(() => c.exports.ping())));
// deno-lint-ignore no-explicit-any
show("E store.hostFailure", errStr((c as any).handle.componentInstances[0].store.hostFailure));
deferred.drop();
}
await turn();
show("unhandled(final)", unhandled);
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 for the same reason as #354.
lower_stream(cx, v, t):return cx.inst.handles.add(ReadableStreamEnd(v))(definitions.py 1802-1805) runs inside the admittedcanon_lift'slower_flat_values; a trap in a later argument ends the store. The reference has no notion of a host source (ReadableStreamPython object) whose pump starts before admission, nor of a host-visible "locked reader".CANCELLED_BEFORE_STARTEDagain models "not entered ⇒ nothing transferred" for the caller's values.Contract: §"Streams and futures": "The runtime facade owns pumping and closes activity retention on end/drop"; "Producer cleanup follows consumption. Whenever the pump permanently stops consuming an unfinished
ReadableStream, including after reader drop or a lowering/write failure, it callsreader.cancel()before releasing the reader lock" — the issue's case is subtly outside the literal wording: the orphan pump never "permanently stops", it parks forever onwriteAllagainst a readable end nobody holds. "a still-deferred handle is refused loudly" (refusal implies no transfer) and theSyncEntryBusyretry clause apply as in #354. §"Error model": poisoned-instance refusal "occurs before entering". No clause contradicts the issue; none literally mandates teardown of arguments of a refused call either — the obligation is by inference from "facade owns pumping" plus the refusal semantics. Code confirms:#lowerParamsscope holds only borrow releases;releaseAsyncArgs(instantiate.ts:1287-1298) is import-side only;#wrapExportFn/#buildSyncFormrunrelease()only.Wasmtime: no equivalent. A host passes
StreamReader/FutureReadervalues;lower_to_indextransfers them into the guest table duringlower_args, and any later-argument failure tripsset_trapped()(func/typed.rs:265-267), killing the store — there is no live-store "call refused after partial lowering" state. Wasmtime also has no host-native-source adapter (noReadableStreampump), so the "locked source" artifact has no counterpart.Verdict: CONTRACT-ONLY (by inference) — spec silent; the contract's pumping-ownership and refusal clauses carry it, but the issue should say the teardown obligation for refused calls is implied, not quoted. The one genuine POLICY fork inside it: whether a
Stream<T>handle passed to a refused call is un-consumed (#consumedreverted) or torn down; the contract's retry clause favors un-consume for theSyncEntryBusyarm.wasmtime: no equivalent — partial lowering failure poisons the store; no host-source pump exists.
Severity: keep P2 — permanently locked embedder
ReadableStreamand a parked pump on every pre-entry failure; no corruption, and (as the issue checked) the orphan pump does not suppress deadlock verdicts.Notes: Case E (import result record with a deferred
Future; conversionTypeErrorvanishes,store.hostFailureundefined) is a different site (ok(out)at instantiate.ts:806) and belongs with #356/#118, as the issue itself notes — recommend moving it out of #355's scope. Thestream<own<R>>leak sub-claim is by code reading only (no fixture); mark it as such in the issue.lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Promote P2 → P1, alongside #354. The principal probes still invalidate an undelivered stream handle, strand its writer, and leave a natural source locked with no cancellation. These are lost usable capabilities on failed/refused admission, including a valid-but-still-deferred Future argument.Implement with #354 around an explicit ownership commit/admission boundary: restore untransferred existing handles; terminate newly created pumps/ends on failure; do not reclaim values already transferred by an entered call. Compound conversion prefixes and asynchronous pre-entry refusals also matter.
Correction to case E: the published probe does not demonstrate a swallowed import-result conversion failure. It creates
fd.exports.run()before awaiting a second instantiation, allowing that Future to materialize. Re-running while printing the returned status givesRETURNED(2), so no conversion error occurred; the fixture does not consume those returned ends. A follow-up with a guaranteed-deferred Future does throw the expectedTypeError, while still leaving the partially created stream source locked. Keep the partial-result cleanup finding, but remove the “conversion error vanished entirely” inference. #356 tracks import-side failure cleanup.lannbot commentedon Sep 13, 2026
Verified against current main
ae86a4aafter #369 (and #367), Deno 2.9.5. Resolved by #369 under its documented destructive-transfer policy; closing.Replayed original export-side leak probes in plain and JSPI modes, plus a follow-up for partial opaque-handle transfer:
cancelled:0) because its pump was deferred and never started.Case C's explicit late rejection now produces an unhandled rejection if the host attaches no observer: scalar preparation rejected the call before acquiring/observing its Promise source. This is not an orphaned runtime pump; the source was never accepted, and the host retains responsibility for observing it. A diagnostic test should attach its own source rejection observer rather than requiring a failed structural-validation pass to start all producers.
The earlier instruction to cancel every refused source is superseded: an unacquired source should stay usable. #356 remains open and reproduces separately for stream/future arguments already handed to an import whose result conversion subsequently fails.