镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

streams: reader-drop race loses an owned ReadableStream element without disposal #342

Closed

Description

@lannbot

Summary

batches()'s reader/gone race silently drops an already-enqueued own
element instead of releasing it, when the consumer drops its handle in the
same turn a ReadableStream controller enqueues a value.

Location

const r = await Promise.race([reader.read(), gone]);
if (r === READER_GONE) return;

const r = await Promise.race([reader.read(), gone]);
if (r === READER_GONE) return;

Authority

contracts/embedder-api.md, "Streams of resources" (stream<own<R>>):

Untaken elements are destroyed. Failed chunk conversion releases its
converted prefix; short writes, cancellation, and peer faults release the
undelivered tail. Cleanup attempts every element and preserves an existing
operation error; a standalone cleanup error is reported.

An element the pump never delivers to the guest is "untaken" by this
definition regardless of which side loses the race — the contract does not
carve out an exception for "the read had already resolved when reader-gone
also resolved."

Expected

When the consumer's handle drops (out.drop()) in the same turn a pending
reader.read() settles with a real value, one of two outcomes satisfies the
contract:

  • the pump takes the element and then tears it down through the untaken-tail
    path (disposed=1), or
  • the pump cancels the reader before the read can resolve, so the value never
    reaches the pump and the source cancel() hook fires (cancelled=1,
    disposed deferred to the source's own cleanup).

Either way, the Ticket element must not simply vanish unreleased.

Actual

Promise.race([reader.read(), gone]) resolves to gone because it was
already-settled/faster, and the branch returns immediately without ever
awaiting or inspecting the reader.read() result. The already-resolved read
promise (and the Ticket instance it carries) is discarded with no
[Symbol.dispose]() call and no reader.cancel() — the finally block's
if (!completed) await reader.cancel() still runs, but by then the value was
already pulled off the stream via read(), so cancel() on an empty reader
does not recover it and the source's cancel() hook never fires either
(observed cancelled=1 below is from a separate prior scenario in the
repro; see actual output).

Verified twice with a fresh Translator instance (deterministic, not a
transient scheduling fluke):

{"disposed":0,"cancelled":1,"locked":false}

disposed=0 — the enqueued Ticket was never disposed.
cancelled=1 — misleading: this counts the source's cancel() invocation,
which does fire (the finally cancels the reader), but arrives too late to
stop the value that was already resolved via read() and thrown away by the
race. The element itself is unreachable and unreleased.

Repro

Adapting the verified scratch harness to repo-relative paths (run from the
repository root):

// tools/repro-stream-reader-drop.ts
import { instantiate } from "./runtime/src/embedder/mod.ts";
import { Translator } from "./runtime/src/shim/mod.ts";

const translator = await Translator.create(
  await Deno.readFile("./translator/translator_shim.wasm"),
);
const turn = () => new Promise<void>((r) => setTimeout(r, 0));

async function fixture(name: string, imports: Record<string, unknown> = {}) {
  const componentBytes = await Deno.readFile(
    `./runtime/tests/embedder/${name}.wasm`,
  );
  const { plan, adapters } = translator.translate(componentBytes);
  return await instantiate({ plan, adapters, componentBytes }, imports, {
    jspi: false,
  });
}

let disposed = 0, cancelled = 0;
class Ticket {
  [Symbol.dispose]() {
    disposed++;
  }
}
const c = await fixture("stream-host", { "host:streams/api": { ticket: Ticket } });
let ctl!: ReadableStreamDefaultController<Ticket>;
const src = new ReadableStream<Ticket>({
  start(c) {
    ctl = c;
  },
  cancel() {
    cancelled++;
  },
});
const out = await c.exports.passTickets(src);
await turn();
out.drop();
ctl.enqueue(new Ticket());
await turn();
console.log(
  "Web Stream reader-drop/late-enqueue:",
  JSON.stringify({ disposed, cancelled, locked: src.locked }),
);
$ deno run --allow-read tools/repro-stream-reader-drop.ts
Web Stream reader-drop/late-enqueue: {"disposed":0,"cancelled":1,"locked":false}

Expected disposed:1 (or an equivalent guaranteed release); got disposed:0.

Suggested fix direction

Don't let gone short-circuit past an already-resolved reader.read(). One
shape: race first only to detect reader-loss, then drain any read that had
already settled concurrently before returning, e.g.

const raced = await Promise.race([
  reader.read().then((r) => ({ kind: "read" as const, r })),
  gone.then(() => ({ kind: "gone" as const })),
]);
if (raced.kind === "gone") {
  // A read may have already resolved in the same microtask turn; drain it
  // so its element reaches the untaken-tail release path instead of being
  // silently discarded.
  ...
  return;
}

The exact mechanism is an implementation choice; the observable requirement
is that no stream<own<R>> element that a producer successfully handed to
read() can vanish without either reaching the guest or being released
through the untaken-element path.

Environment

  • Baseline commit: 1a4f5e1c6f3fc906ce576acdc4499ef2745d6e41
  • Verified twice with a fresh Translator instance per run (ruled out
    cross-run state contamination)

Activity

  1. added
    bugSomething isn't working
    p1Correctness bugs likely to impact consumers; high-priority missing features
    on Sep 12, 2026
  2. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Triage: P1 / bug — first in this batch

    Current main (1a4f5e1) still has the race. Independently reproduced at the lowerStreamSource/hostStreamFor seam with a release-counting codec: { disposed: 0, cancelled: 1, locked: false }. Source inspection confirms that the resolved read is discarded before it reaches packChunk and the untaken-element release path. This probe verifies pump ownership accounting; it does not rerun the report's compiled-component fixture.

    Ordinary producer/consumer timing is sufficient; no malformed host value is needed. Resource-bearing accept streams make this a plausible consumer leak, hence P1.

    Clarification: the source cancellation does run. It cannot reclaim an element already removed from the source queue by read(). cancelled: 1 therefore does not satisfy element disposal. The contradictory cancellation narrative in the report should not obscure this distinction.

    Acceptance: retain the pending read, cancel to settle an outstanding pull, and account for any value already obtained exactly once. Cover drop-before-enqueue, enqueue-before-drop, EOF, rejection, and batched owned elements; preserve the existing operation-error precedence. Do not wait for the pending read before initiating cancellation.

    Related to closed #183 / PR #341, but distinct: cancelling the unfinished source is now implemented; disposing a read result lost by the race is not. Keep this issue open independently.

    Batch order: #342 → #345 → #343 → #346 → #347 → #344. These are execution priorities, not hard dependencies.

  3. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Component Model / Wasmtime verification

    P1 unchanged; this is a polyengine embedding-contract bug, not a demonstrated Canonical ABI violation.

    Authorities checked: Component Model 7c676115e93cd7d54c1732d95c54c6a3de7c5ae0; Wasmtime 4675ee16b703b33948073a5ff6b961367371e7a1 (the repository pin).

    The reference's SharedStreamImpl.read/write transfers only the rendezvous count; drop() notifies a pending copy with DROPPED. It does not define a JS ReadableStream pump, Promise races, or automatic destruction of elements pulled ahead of a rendezvous. Those stronger obligations are in polyengine's “Untaken elements are destroyed” contract.

    Wasmtime is not evidence that such cleanup happens automatically. Its StreamProducer documentation retains surplus items in a host buffer and explicitly warns that buffering Resource<_> during zero-length readiness reads can leak if the guest closes before consuming. Its cancellation documentation also warns against buffering late values after returning Cancelled (lines 717–723).

    This supports the ownership concern, but neither proves this exact JS race exists in Wasmtime nor supplies a native disposal guarantee. Keep the fix tied to polyengine's explicit cleanup policy. Wasmtime comparison here is source/documentation inspection, not an exact native race reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingp1Correctness bugs likely to impact consumers; high-priority missing features

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions