Skip to content

webstreams: writer.closed settles before sink abort() completes when first observed during erroring #65726

Description

@trevnorris

Version

v24.19.0 through v24.20.0, and current main (v24.18.1 behaves per spec)

Platform

All (verified on Linux x64)

Subsystem

webstreams

What steps will reproduce the bug?

Abort a WritableStream whose sink has a pending operation, and access writer.closed for the first time after erroring has begun but before the sink's abort() promise settles:

'use strict';
const pending = [];
function deferred() {
  const d = {};
  d.promise = new Promise((res, rej) => { d.resolve = res; d.reject = rej; });
  return d;
}

const ws = new WritableStream({
  write() { const d = deferred(); pending.push(d); return d.promise; },
  abort() { const d = deferred(); pending.push(d); return d.promise; },
});
const w = ws.getWriter();

w.write('chunk').catch(() => {});
w.abort(new Error('boom')).catch(() => {});

setTimeout(async () => {
  // First access to writer.closed: sink.abort() has NOT settled yet
  // (its promise is parked in `pending`).
  const sentinel = {};
  let state;
  try {
    state = (await Promise.race([w.closed, Promise.resolve(sentinel)])) === sentinel
      ? 'pending' : 'fulfilled';
  } catch (e) { state = `rejected: ${e.message}`; }
  console.log(process.version,
              'writer.closed while sink abort() pending:', state,
              '| sink abort settled:', pending.length === 0);
  process.exit(0);
}, 10);

Output:

v24.18.1 writer.closed while sink abort() pending: pending  | sink abort settled: false
v24.19.0 writer.closed while sink abort() pending: rejected: boom | sink abort settled: false
v24.20.0 writer.closed while sink abort() pending: rejected: boom | sink abort settled: false

Note the divergence only occurs when writer.closed is first accessed after erroring begins. If the getter was already touched while the stream was 'writable', all versions follow spec timing — which makes this easy to miss in tests that eagerly attach closed handlers.

How often does it reproduce? Is there a required condition?

Deterministic. Requires the first access of writer.closed to happen in the window where the stream has begun erroring but the sink's abort() promise has not settled.

What is the expected behavior? Why is that the expected behavior?

writer.closed must remain pending until the sink's abort() promise settles.

Per the spec, writer.[[closedPromise]] is rejected only in WritableStreamRejectCloseAndClosedPromiseIfNeeded, which WritableStreamFinishErroring invokes only from the upon fulfillment/rejection reactions of the promise returned by the sink's abortAlgorithm. The state is set to "errored" earlier in FinishErroring, so "state is errored" and "closedPromise is rejected" are deliberately not the same instant: user code awaiting writer.closed is guaranteed the sink's abort/cleanup has completed. Node ≤ 24.18.1 and the spec reference implementation both behave this way.

What do you see instead?

On v24.19.0+ a writer.closed first observed in that window comes back already rejected (with the stored error), i.e. it settles before the sink's abort/cleanup has finished. Code that uses await writer.closed.catch(...) as the "teardown complete" signal now proceeds while abort() is still running.

Additional information

Cause: v24.19.0's lazy materialization of the writer's [[closedPromise]] record (writerClosedPromise() in lib/internal/webstreams/writablestream.js, introduced in #63876). The lazy derivation maps stream state 'errored' directly to an already-rejected record:

switch (stream[kState].state) {
  case 'writable':
  case 'erroring':
    close = PromiseWithResolvers();
    break;
  case 'closed':
    close = resolvedRecord();
    break;
  default: // 'errored' — but the spec sets 'errored' BEFORE the sink abort runs
    close = rejectedHandledRecord(stream[kState].storedError);
}

but the spec reaches state "errored" while the deferred rejection point (abort settlement) is still in the future, so deriving "rejected" from the state alone is incorrect for exactly the FinishErroring-with-pending-abort window.

Related: on v24.20.0 this same early-materialized record (which has reject: undefined) caused an uncaught internal TypeError: closeCache.reject is not a function in writableStreamRejectCloseAndClosedPromiseIfNeeded when the abort later settled, crashing the process. That crash was fixed on main by #64825 (staged for v24.x), but the fix guards the settle call with isPromisePending() and leaves the early rejection in place — writerClosedPromise() is unchanged on current main, so the spec-conformance issue described here survives the crash fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions