Repository navigation
streams: Future.drop() before await on a deferred future is executed *after* the read — the disposed handle still drives the guest and yields the value; the same program with one extra turn rejects DroppedError blaming the write end #365
Description
Future.drop() before await on a deferred future is executed after the read — the disposed handle still drives the guest and yields the value; the same program with one extra turn rejects DroppedError blaming the write end
Severity: P3 Confidence: medium
Location: runtime/src/embedder/streams.ts:747-778 (Future.deferred / adopt: adopt issues h.readResult() inside the #hostP resolution, ahead of the .then((h) => h.drop()) queued at :915); :812-845 (#read() has no #dropped guard); :853-861 (DroppedError text "the future's write end was dropped without a value" for a DROPPED that the host's own readable-end drop produced). Low-level side: runtime/src/exec/host_streams.ts:1361-1366 (readResult answers DROPPED for any shared.dropped, including the wrapper's own drop()).
Authority: embedder-api.md §"Streams and futures": "Disposal is total and silent (#182). drop()/cancel() never throw…"; "Awaiting a future whose write end dropped without a value rejects DroppedError" (the DroppedError text is reserved for the writer-gone case). definitions.py SharedFutureImpl.read: assert(not self.dropped …) — the reference has no notion of reading through a dropped readable end at all.
Expected: after drop(), awaiting the handle has one deterministic outcome regardless of whether the host end had materialized yet. Whichever outcome is chosen, a dropped handle must not issue a fresh read and pump the guest, and a rejection produced by the host's own drop must not claim "the future's write end was dropped without a value".
Actual (probe4b.ts, future-user guest make-future, run twice, deterministic):
deferred: drop() then await -> resolved:6
materialized: drop() then await -> rejected:DroppedError:the future's write end was dropped without a value
materialized: await parked, then drop() -> resolved:6 (legit: op completed before drop)
deferred: drop(), wait, then await -> rejected:DroppedError:the future's write end was dropped without a value
Line 1 vs line 4 is the same host program with one setTimeout(0) between drop() and then(). In line 1 adopt() runs readResult() (which pumps the guest to completion and consumes the value) before the deferred h.drop() callback runs, so the disposed handle performs a read and takes the value; in line 4 the drop landed first and the read is refused — with a message attributing the drop to the writer. The guest writer observes DROPPED in the refused cases (correct).
Repro: probe4b.ts ; no unhandled rejections either way.
Distinct from known issues: not #342–#347; not #168 (teardown of a parked write). The dispatch's item "await after drop() → branded DroppedError" is satisfied only on one of the two timings.
Fix direction: record #dropped in #read() and reject deterministically (a TypeError naming the disposed handle, or a DroppedError whose text says the host dropped its readable end); in adopt, skip readResult() when #dropped is already set so a dropped deferred handle never issues a read. Note this file is shared with the embedder-handles track; the low-level readResult DROPPED-for-own-drop conflation at host_streams.ts:1361 is the part inside this track's territory.
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.
harness.ts
// Shared harness for host-stream-ends probes. Run from the repo root with:
// deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
const REPO = "<REPO>/";
export const task = await import(REPO + "runtime/src/task/mod.ts");
export const cabiCtx = await import(REPO + "runtime/src/cabi/context.ts");
export const hostStreams = await import(REPO + "runtime/src/exec/host_streams.ts");
export const embStreams = await import(REPO + "runtime/src/embedder/streams.ts");
export const embErrors = await import(REPO + "runtime/src/embedder/errors.ts");
export const embedder = await import(REPO + "runtime/src/embedder/mod.ts");
export const shim = await import(REPO + "runtime/src/shim/mod.ts");
export const U8 = { kind: "u8" };
export const U32 = { kind: "u32" };
export const { CopyResult, GuestBuffer, SharedStreamImpl, SharedFutureImpl } = task;
export function mkMemory(initial = 1) {
const memory = new WebAssembly.Memory({ initial });
const view = {
addrType: "i32",
get bytes() { return new Uint8Array(memory.buffer); },
get view() { return new DataView(memory.buffer); },
get length() { return memory.buffer.byteLength; },
ptrType: () => "i32",
ptrSize: () => 4,
};
return { memory, view };
}
export function mkCx(view: unknown) {
return new cabiCtx.LiftLowerContext(cabiCtx.mkCanonicalOptions({ memory: view }));
}
export const GUEST_A = Object.freeze({ guest: "a" });
export const GUEST_B = Object.freeze({ guest: "b" });
/** Model of a guest stream op: reclaim inside onCopy (post-pump state). */
export function guestOp(shared: any, kind: "read" | "write", cx: any, ptr: number, len: number, inst: unknown = GUEST_A, t: unknown = U8, reclaimLate = false) {
const buf = new GuestBuffer(t, cx, ptr, len);
const events: { result: number; progress: number }[] = [];
let pendingReclaim: (() => void) | null = null;
const onCopy = (reclaim: () => void) => {
if (reclaimLate) pendingReclaim = reclaim;
else reclaim();
events.push({ result: CopyResult.COMPLETED, progress: buf.progress });
};
const onCopyDone = (result: number) => events.push({ result, progress: buf.progress });
if (kind === "read") shared.read(inst, buf, onCopy, onCopyDone);
else shared.write(inst, buf, onCopy, onCopyDone);
return { buf, events, deliver: () => { pendingReclaim?.(); pendingReclaim = null; } };
}
export function memBytes(memory: WebAssembly.Memory, ptr: number, len: number) {
return [...new Uint8Array(memory.buffer, ptr, len)];
}
export function fill(memory: WebAssembly.Memory, ptr: number, vs: number[]) {
new Uint8Array(memory.buffer).set(Uint8Array.from(vs), ptr);
}
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export async function settled<T>(p: Promise<T>, ms = 20): Promise<string> {
let out = "pending";
p.then((v) => out = "resolved:" + JSON.stringify(v instanceof Uint8Array ? [...v] : v),
(e) => out = "rejected:" + (e?.constructor?.name ?? typeof e) + ":" + (e?.message ?? String(e)));
await new Promise((r) => setTimeout(r, ms));
return out;
}
let translator: any = null;
export async function fixture(rel: string, imports: Record<string, unknown> = {}, opts: Record<string, unknown> = {}) {
if (translator === null) {
translator = await shim.Translator.create(
await Deno.readFile(REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm"));
}
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await embedder.instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const guestPath = (n: string) => `examples/guests/build/${n}.component.wasm`;
export const fixturePath = (n: string) => `runtime/tests/embedder/${n}.wasm`;probe4b.ts
// Probe 4b: await-after-drop consistency between deferred and materialized futures.
import { embStreams, fixture, guestPath, settled, turn } from "./harness.ts";
const unhandled: string[] = [];
globalThis.addEventListener("unhandledrejection", (ev: any) => { unhandled.push(String(ev.reason?.message).slice(0, 60)); ev.preventDefault(); });
const c = await fixture(guestPath("future-user"));
{
const f = c.exports.makeFuture(5);
f.drop();
console.log("deferred: drop() then await ->", await settled(f.then((v: number) => v), 50));
}
{
const f = c.exports.makeFuture(5);
await turn(); // let the export call resolve so the host end materializes
f.drop();
console.log("materialized: drop() then await ->", await settled(f.then((v: number) => v), 50));
}
{
const f = c.exports.makeFuture(5);
await turn();
const a = f.then((v: number) => v); // read parks first
f.drop();
console.log("materialized: await parked, then drop() ->", await settled(a, 50));
}
{
// deferred: drop(), then guest value arrives, then await much later.
const f = c.exports.makeFuture(5);
f.drop();
await turn(); await turn();
console.log("deferred: drop(), wait, then await ->", await settled(f.then((v: number) => v), 50));
}
await turn();
console.log("unhandled:", JSON.stringify(unhandled));
Deno.exit(0);
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:
drop(EndT, …)→e = inst.handles.remove(hi): after dropping itsreadable end the reader holds no handle, so a subsequent
future.readon thatindex traps in
handles.get(i);SharedFutureImpl.read—assert(not self.dropped and dst_buffer.remain() == 1)— the reference treats reading via adropped future as unreachable.
CopyEnd.drop—trap_if(self.copying())— thespec does not allow dropping a readable end with a read in flight at all
(issue's line 3 "await parked, then drop() → resolved" is therefore a
polyengine policy, not a spec outcome; the issue correctly calls it legit under
the contract). Net: the spec supports exactly one thing the issue says — a
dropped end never issues a read — and is otherwise silent on facades.
Contract: :429-430 "Disposal is total and silent …
drop()/cancel()never throw, never return a promise, and never surface a failure of the
producing call." :413
class DroppedError … // awaiting a dropped future rejects with thisand :426-427 "Awaiting a future whose write end dropped without avalue rejects
DroppedError". The class comment is the closest the contractcomes to "await after own drop →
DroppedError"; it does not distinguish whichend dropped. Nothing in the contract makes
DroppedError.messageAPI (:148"
Trap.messageis diagnostic, not API" — the same discipline is the only onestated). Line 1 ("deferred: drop() then await → resolved:6") violates "Disposal
is total":
embedder/streams.ts:769-778adopt()issuesh.readResult()when#readingis set, before the.then((h) => h.drop())queued at:915runs,and
:812-845#read()has no#droppedguard — so a disposed handle pumpsthe guest and consumes the value. Line 4 (
DroppedError) is the class thecontract predicts; only its text ("the future's write end was dropped") is
wrong, because
host_streams.ts:1361-1366answersDROPPEDfor anyshared.droppedincluding the wrapper's own drop.Wasmtime: different behavior — deterministic use-after-close error, no
timing dependence.
FutureReader::close(:1423-1425, :1479-1485) replacesself.idwith au32::MAXsentinel; any laterpipe/closefails on thetable lookup ("Returns an error if this future has already been closed",
:1413-1415). Handles exist immediately (no deferred materialization), so there
is no window in which a close can be reordered after a read. The writer-side
effect matches polyengine's:
host_drop_reader(:2414-2456) deliversReturnCode::Droppedto a parked guest writer, or arms it for the writer's nextwrite. There is no
Drop for FutureReader(see header); the dispatch's"dropping the reader before polling" applies only to
GuardedFutureReader(:1638-1650), and there is no
FutureReader::read.Verdict: CONTRACT-ONLY. The spec has no facade or deferred handle; the
contract's "Disposal is total" decides line 1 (a dropped handle must not read),
and the class comment at :413 makes
DroppedErrorthe expected class forawait-after-drop. The message complaint is real but non-normative.
wasmtime: different behavior — use-after-close is a deterministic error regardless of ordering; readers are never deferred.
Severity: keep P3. No unhandled rejection, no poisoning, guest writer sees
the correct DROPPED; the defect is a timing-dependent outcome and a
disposed-handle-still-acts violation on the embedder side.
Notes: (1) "Expected" should name the outcome:
DroppedError(per :413), withtext saying the host dropped its readable end — do not switch to
TypeError,which the contract reserves for transfer/busy refusals. (2) Fix in
adopt()(skip
readResult()when#dropped) plus a#droppedguard in#read()givesthe deterministic outcome; the
host_streams.ts:1361DROPPED-for-own-dropconflation only needs the message fixed, since the low-level result code is
correct for the shared object's state. (3) Line 3 (drop during a parked read
completes with the value) is spec-divergent (
CopyEnd.dropwould trap) butcontract-sanctioned ("never throw"); not a defect, but worth a comment in the
contract if anyone asks.
lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Promote P3 → P2. Immediate drop-then-await still resolves 6; inserting a turn makes it rejectDroppedError. A disposed handle initiating a fresh read and driving the producer is observable behavior, not just diagnostic text.Make own-drop state effective before deferred adoption/read initiation, while preserving completed memoized results and the existing rule that disposal never throws. Use
DroppedErrorfor await-after-own-drop consistently with the contract's handle comment; do not relabel transfer/busy errors. Pair with #351's ownership guards, keeping created-writer abandonment distinct from dropping a lifted readable facade.