Repository navigation
streams: drop() on a handle whose readable end was transferred to a guest still drops the guest-owned shared object #351
Description
drop() on a handle whose readable end was transferred to a guest still drops the guest-owned shared object
Severity: P1 Confidence: high
Location: runtime/src/embedder/streams.ts:436-450 (Stream.drop), :906-916 (Future.drop); runtime/src/exec/host_streams.ts:1395-1411 (HostFuture.drop, lowering.lowered branch at 1398), :791-792 (alsoOnLowered flips lowered on every lower, including for "lifted"-kind wrappers)
Authority: embedder-api.md §"Streams and futures" — "Lowering a lifted handle back (the identity round trip) ends retention"; "read through a handle … already passed to a guest rejects TypeError naming the transfer" (the runtime's own message: "the guest owns its readable end"). definitions.py lift_async_value (cx.inst.handles.remove(i) — the lift removes the end from the lifter) and drop(EndT, …) (inst.handles.remove(hi) — a drop operates only on an end in the dropper's own table). After lowering a lifted handle back, the host holds no end of the stream/future.
Expected: drop() / [Symbol.dispose]() on the stale host facade is state cleanup only (the contract's "An unlowered future just releases state" spirit); the guest that now owns the end is unaffected. The handle already refuses read/await on exactly this ground.
Actual:
- Stream:
Stream.drop()→readable.drop()→SharedStreamImpl.drop(). The guest's parked read on that end gets DROPPED, i.e. a clean-looking EOS — "wrong data reported as success" in the runtime's own wording. Reprop4_stale_drop.ts(stream-echo, single store): host liftsout1, passes it back (echoDoubled(out1)→out2), thenout1.drop():drop() out2 third read (expect [12]): [] [Symbol.dispose] out2 third read (expect [12]): [] control(no-op) out2 third read (expect [12]): [12] - Future:
Future.drop()→HostFuture.drop(); becauselowering.loweredwas flipped by the host→guest lower anddeliveredis false, it callsabandonSharedFuture(shared, "the host dropped the writable end of this future without writing a value"). The host never held the writable end; the guest reader traps at its rendezvous and its instance is poisoned. Reprop4c_stale_future_drop.ts(producerfuture-disposal.idle()never writes; consumerfuture-user.doubleFuture(f)parks reading; thenf.drop()):Caveat: the future repro composes two fixtures (two stores; accepted today per embedder: cross-store stream/future transfer stalls consumers and loses producer faults #344) because no single fixture both produces an unwritten future and parks a consumer on it. The decision path (drop-stale doubleFuture(stale f): rejected Trap: future.read can never complete: the host dropped the writable end of this future without writing a value drop-stale later export call on consumer: threw Trap: future.read can never complete: ... control doubleFuture(stale f): TIMEOUT (legitimately pending) control later export call on consumer: ok!delivered && lowering.lowered && !shared.dropped) reads only wrapper-local state and is store-independent; the single-store attemptp4d_stale_future_single_store.tsshows the same guest delivering before the window opens (control), not a different code path.
Repro:p4_stale_drop.ts,p4c_stale_future_drop.ts(above)
Distinct from known issues: embedder: cross-store stream/future transfer stalls consumers and loses producer faults #344 is about accepting a cross-store lower; this is about a post-transferdrop()acting on an end the host no longer holds, reachable single-store for streams. No open issue covers post-transfer disposal.
Fix direction:Stream.drop()/Future.drop()should short-circuit when#consumedis set (release facade state, do not touch the shared object); for created-kind streams (host holdsStreamWriter) dropping the readable facade can remain the writer-close alias if that is intended, but a lifted-kind wrapper with a disarmed activity must never drop/abandon. AlternativelyHostFuture.dropshould only take the abandonment branch when this wrapper is the created kind (the host is the spec writer), not merely because the object was lowered once.
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.
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);p4_stale_drop.ts
// Probe 4: drop()/cancelRead() on a handle whose readable end has been
// transferred to a guest. Does the stale host handle still act on the shared
// object the guest now owns?
import { caught, errStr, fixture, guest, show, streamsMod, timeout, turn, unhandled } from "./h.ts";
const { Stream } = streamsMod;
async function scenario(label: string, act: (stale: InstanceType<typeof Stream>) => void) {
const c = await fixture(guest("stream-echo"), {}, { jspi: false });
const { stream: src, writer } = Stream.create<number>();
// Guest-created stream (lifted, host holds its readable end).
const out1 = await c.exports.echoDoubled(src);
// Hand out1 back to the guest: guest now owns out1's readable end, and
// produces out2 from it. The host's `out1` facade is stale (#162 transfer).
const out2 = await c.exports.echoDoubled(out1);
const staleRead = errStr(await caught(() => out1.read(1)));
const w = writer.writeAll([1, 2]);
const first = await out2.read(1);
show(label + " first out2 chunk", first);
await w;
// Host acts on the STALE handle.
act(out1);
await turn();
const w2 = writer.writeAll([3]);
const second = await timeout(out2.read(1), 300);
show(label + " out2 after acting on stale handle", second);
show(label + " writer.writeAll([3]) settled", await timeout(w2, 300));
const third = await timeout(out2.read(1), 300);
show(label + " out2 third read (expect [12])", third);
show(label + " stale read refusal", staleRead);
out2.drop();
src.drop();
await turn();
}
await scenario("drop()", (s) => s.drop());
await scenario("[Symbol.dispose]", (s) => s[Symbol.dispose]());
await scenario("control(no-op)", () => {});
await turn();
show("unhandled", unhandled);p4c_stale_future_drop.ts
// Probe 4c: Future.drop() on a stale lifted handle whose readable end was
// transferred to a guest that is parked reading it. The host never held the
// writable end, yet drop() abandons the future "as the writer" and the guest
// reader traps (poisoning its instance).
//
// Producer: future-disposal `idle()` (guest-created future<u32>, never
// written). Consumer: future-user `doubleFuture(f)` (parks on future.read).
// Two fixtures = two stores; cross-store lowering is accepted today (#344).
// The drop path under test (`HostFuture.drop` -> abandonSharedFuture when
// `lowering.lowered && !delivered`) is store-independent; the only artifact
// is that the consumer's store must be nudged to deliver the event.
import { emb, errStr, fixture, guest, show, timeout, turn, unhandled } from "./h.ts";
for (const mode of ["drop-stale", "control"]) {
const producer = await fixture(emb("future-disposal"), {}, { jspi: false });
const consumer = await fixture(guest("future-user"), {}, { jspi: false });
const f = producer.exports.idle(); // lifted Future<u32>, never written
await turn();
const p = consumer.exports.doubleFuture(f); // guest parks on future.read
const outcome = p.then(
(v: unknown) => "resolved " + v,
(e: unknown) => "rejected " + errStr(e),
);
await turn();
if (mode === "drop-stale") f.drop(); // host holds NO end of this future now
await turn();
let nudge: string;
try {
const g = consumer.exports.makeFuture(0);
nudge = "ok";
g.drop();
} catch (e) {
nudge = "threw " + errStr(e);
}
show(mode + " doubleFuture(stale f)", await timeout(outcome, 300));
show(mode + " later export call on consumer", nudge);
await turn();
}
show("unhandled", unhandled);p4d_stale_future_single_store.ts
// Probe 4d (single store): the natural `using`-style pattern — pass a lifted
// future to a guest export, then dispose the (now stale) host handle
// synchronously in the same turn.
import { errStr, fixture, guest, show, timeout, turn, unhandled } from "./h.ts";
for (const mode of ["drop-stale", "control"]) {
const c = await fixture(guest("future-user"), {}, { jspi: false });
const f = c.exports.makeFuture(1); // guest-created future; guest writes 2 later
await turn(); // let the export call materialize f (deferred -> lifted)
const p = c.exports.doubleFuture(f); // guest parks on future.read
if (mode === "drop-stale") f.drop(); // host no longer holds any end of f
const r = await timeout(
p.then((v: unknown) => "resolved " + v, (e: unknown) => "rejected " + errStr(e)),
500,
);
show(mode + " doubleFuture(f)", r);
let after: string;
try {
const g = c.exports.makeFuture(5);
after = "ok " + (await timeout(Promise.resolve(g).then((v: unknown) => v), 300));
} catch (e) {
after = "threw " + errStr(e);
}
show(mode + " next export call on the instance", after);
await turn();
}
show("unhandled", 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:
lift_async_value—e = cx.inst.handles.remove(i)…return e.shared:the lifter's table entry is removed;
lower_stream/lower_future—cx.inst.handles.add(ReadableStreamEnd(v)): the receiver gets a fresh end.drop(EndT, …)—e = inst.handles.remove(hi)…e.drop(): a drop acts only onan end in the dropper's own table. Concurrency.md:231-232 / :575-577: a
stream/future in a signature "always refers to the transfer of unique
ownership of the readable end". After the host lowers a lifted readable end
back into a guest it holds no end; in the reference there is simply nothing
the transferring party can
drop. For the future half specifically:SharedFutureImpl.dropasserts a pending buffer is aReadableBuffer(only aparked writer can see DROPPED),
future_copy.on_copy_doneassertsresult != DROPPED or event_code == FUTURE_WRITE, andWritableFutureEnd.droptraps the writer (
trap_if(self.state != CopyState.DONE)). A parked guestfuture reader can only be settled by the writer writing; the spec has no path
by which a third party aborts it.
Contract: embedder-api.md:499-502 — "Lowering a lifted handle back (the
identityround trip) ends retention …readthrough a handle — or awaiting aFuture— already passed to a guest rejectsTypeErrornaming the transfer."The contract is silent on
drop()after transfer; :429 "Disposal is totaland silent …
drop()/cancel()never throw" forbids throwing, leaving no-op asthe only consistent reading. The abandonment clause (:532-537, #90) is written
for the case where "the host [is] the writer" ("the host dropped the writable end
without writing a value"); it does not license abandonment of a future whose
writable end is guest-owned. The code comment at
runtime/src/embedder/streams.ts:284-285("Transfer relinquishes this handle'sreadable end, not StreamWriter's writable end. drop/cancelRead remain available
for cleanup") shows the post-transfer
drop()was intended for created-kindstreams where the host still holds the writer; the lifted-kind case was not
distinguished.
host_streams.ts:1278(hostFutureForpasses() => lowering.lowered = true) plus:1398(!delivered && lowering.lowered && !shared.dropped→abandonSharedFuture) confirms the issue's decision path.Wasmtime: different behavior in the natural pattern; same hole if the host
opts in. Params are lowered by value (
call_read_stream(accessor, rx, …)incomponent-async-tests/tests/scenario/streams.rs:217-221 moves
rx), readersare not
Clone, andFutureReader/StreamReaderhave noDrop, so the stalefacade normally ceases to exist. But
&'_ T => Tis lowerable(func/typed.rs:772-773) and
lower_transmit_to_index(futures_and_streams.rs:4632-4647) does not invalidate the host's
TableId; a retained reader'sclose()→host_drop_reader(:2414-2484) setstransmit.read = Droppedunconditionally with no ownership check, delivering
Droppedto a parked guestwriter and silently overwriting a parked guest read (a later guest read hits
bail_bug!("expected ReadState::Open"):3784-3786). Note wasmtime's reader-sidedrop never traps the guest reader; the only "drop without write" error is
raised on the dropping writer (
guest_drop_writable:3253-3257, matching thespec). Nothing in wasmtime corresponds to polyengine's "abandon as writer" path
for a future the host never wrote.
Verdict: SPEC-BACKED (stream half and future half). The reference's
ownership model makes the transferring party's end nonexistent; polyengine's
facade acting on the shared object after transfer — and, for futures, acting as
the writer it never was — deviates. The precise JS shape of the fix (no-op
drop) is contract-derived ("never throw"), but the "guest unaffected" expectation
is the spec's.
wasmtime: different behavior — by-value lowering makes the stale handle vanish; the retained-
&readerpath is equally unguarded but is a documented ownership violation, and it never traps a reader.Severity: keep P1. Stream half is single-store, public API, and yields a
clean-looking EOS ("wrong data reported as success"); future half poisons an
innocent guest instance with a false message. Fix direction in the issue is
right: lifted-kind wrappers must not drop/abandon after
#consumed; created-kindStream.drop()may remain a writer-close alias (that is spec-conformant:WritableStreamEnd.drop→shared.drop()→ parked reader gets DROPPED).Notes: (1) Issue's fix-direction alternative "only take the abandonment branch
when this wrapper is the created kind" is the more precise fix —
loweredisthe wrong predicate because a lifted wrapper is also lowered on re-transfer.
(2) The two-store future repro is a presentation weakness, not a correctness
one; the decision path reads only wrapper-local state (
host_streams.ts:1398).lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Keep P1. The single-store stream probe still returns[]instead of[12]after disposal of the transferred facade. The two-store future probe also reproduces, with its existing cross-store caveat unchanged.Treat this as ownership enforcement, not disposal diagnostics: a stale readable facade must not operate a guest-owned end. Preserve the independently owned
StreamWriterand distinguish created versus lifted future ownership. Pair regression coverage with #365, but do not close either as a duplicate: post-transfer disposal and pre-materialization disposal are different transitions.lannbot commentedon Sep 13, 2026
Rechecked current main
ae86a4aafter #367/#369, Deno 2.9.5. Keep P1/open. The original single-store stream repro is unchanged: post-transferdrop()/Symbol.disposestill makes the third read return[]instead of[12]; the no-op control returns[12].The existing two-store future repro is now blocked by #367: both candidate and control reject cross-store transfer with TypeError before the stale-drop window, and the consumer stays healthy. It no longer proves the future half dynamically; use a same-store future fixture before claiming that part revalidated. It does not weaken the independently reproduced single-store stream defect.