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.
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
WritableStreamwhose sink has a pending operation, and accesswriter.closedfor the first time after erroring has begun but before the sink'sabort()promise settles:Output:
Note the divergence only occurs when
writer.closedis 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 attachclosedhandlers.How often does it reproduce? Is there a required condition?
Deterministic. Requires the first access of
writer.closedto happen in the window where the stream has begun erroring but the sink'sabort()promise has not settled.What is the expected behavior? Why is that the expected behavior?
writer.closedmust remain pending until the sink'sabort()promise settles.Per the spec,
writer.[[closedPromise]]is rejected only inWritableStreamRejectCloseAndClosedPromiseIfNeeded, whichWritableStreamFinishErroringinvokes only from the upon fulfillment/rejection reactions of the promise returned by the sink'sabortAlgorithm. 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 awaitingwriter.closedis 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.closedfirst 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 usesawait writer.closed.catch(...)as the "teardown complete" signal now proceeds whileabort()is still running.Additional information
Cause: v24.19.0's lazy materialization of the writer's
[[closedPromise]]record (writerClosedPromise()inlib/internal/webstreams/writablestream.js, introduced in #63876). The lazy derivation maps stream state'errored'directly to an already-rejected record: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 internalTypeError: closeCache.reject is not a functioninwritableStreamRejectCloseAndClosedPromiseIfNeededwhen the abort later settled, crashing the process. That crash was fixed onmainby #64825 (staged for v24.x), but the fix guards the settle call withisPromisePending()and leaves the early rejection in place —writerClosedPromise()is unchanged on currentmain, so the spec-conformance issue described here survives the crash fix.