diff --git a/src/tests/streams/piping/error-propagation.js b/src/tests/streams/piping/error-propagation.js index bcd04050de1..b6197e90656 100644 --- a/src/tests/streams/piping/error-propagation.js +++ b/src/tests/streams/piping/error-propagation.js @@ -237,3 +237,115 @@ export const errorTypePreservationPipeThrough = { strictEqual(reason.code, 'ERR_PIPE'); }, }; + +// The WPT 'starts errored … abort promise' residue: the destination's +// abort() hook returns a PROMISE. Fulfilled: consumed silently, the +// pipe still rejects with the SOURCE error. Rejected: per spec the +// shutdown action's failure REPLACES the rejection reason. +export const destAbortPromiseStates = { + async test() { + // Fulfilled abort promise. + { + const err = new Error('src-err'); + let abortCalled = false; + const rs = new ReadableStream({ + start(c) { + c.error(err); + }, + }); + const ws = new WritableStream({ + abort() { + abortCalled = true; + return Promise.resolve('ignored'); + }, + }); + strictEqual(await rejectionOf(rs.pipeTo(ws)), err); + strictEqual(abortCalled, true); + } + // Rejected abort promise. + { + const err = new Error('src-err'); + const abortErr = new Error('abort-failed'); + const rs = new ReadableStream({ + start(c) { + c.error(err); + }, + }); + const ws = new WritableStream({ + abort() { + return Promise.reject(abortErr); + }, + }); + const reason = await rejectionOf(rs.pipeTo(ws)); + strictEqual(reason, abortErr); + } + }, +}; + +// preventAbort AND preventCancel together on a starts-errored source: +// both suppressions hold and both ends stay un-shut-down. +export const preventAbortAndCancelCombo = { + async test() { + const err = new Error('src-err'); + let abortCalled = false; + const rs = new ReadableStream({ + start(c) { + c.error(err); + }, + }); + const ws = new WritableStream({ + abort() { + abortCalled = true; + }, + }); + strictEqual( + await rejectionOf( + rs.pipeTo(ws, { + preventAbort: true, + preventCancel: true, + preventClose: true, + }) + ), + err + ); + strictEqual(abortCalled, false); + ws.getWriter(); // dest untouched and re-lockable + }, +}; + +// The WPT 'shutdown must not occur until the final write completes' +// shape: the source errors while a write is IN FLIGHT — the +// destination's abort must not run until that write settles. +export const shutdownWaitsForInFlightWrite = { + async test() { + const err = new Error('src-err'); + const events = []; + let releaseWrite; + const parked = new Promise((resolve) => (releaseWrite = resolve)); + let controller; + const rs = new ReadableStream({ + start(c) { + controller = c; + }, + }); + const ws = new WritableStream({ + write() { + events.push('write-start'); + return parked; + }, + abort(reason) { + events.push(`abort:${reason.message}`); + }, + }); + const pipeP = rs.pipeTo(ws); + controller.enqueue('chunk'); + await scheduler.wait(10); + controller.error(err); + await scheduler.wait(20); + // The write is still parked: abort must not have run yet. + strictEqual(events.join(','), 'write-start'); + releaseWrite(); + strictEqual(await rejectionOf(pipeP), err); + strictEqual(events.join(','), 'write-start,abort:src-err'); + }, +}; diff --git a/src/tests/streams/piping/main.js b/src/tests/streams/piping/main.js index 673be6aa5d7..57b52da21c7 100644 --- a/src/tests/streams/piping/main.js +++ b/src/tests/streams/piping/main.js @@ -59,6 +59,9 @@ export { destStartsErroredPreventCancel, errorTypePreservationPipeTo, errorTypePreservationPipeThrough, + destAbortPromiseStates, + preventAbortAndCancelCombo, + shutdownWaitsForInFlightWrite, } from 'error-propagation'; export { diff --git a/src/tests/streams/readable-byte/AGENTS.md b/src/tests/streams/readable-byte/AGENTS.md index df45031dbe7..6ccd9e708ac 100644 --- a/src/tests/streams/readable-byte/AGENTS.md +++ b/src/tests/streams/readable-byte/AGENTS.md @@ -59,6 +59,31 @@ bridge drives to consume TypeScript streams (conduit basics in the identity suite's draining-reader.js). No such global exists under the C++ implementation; `draining-reader.js` asserts both sides. +## WPT map: readable-byte-streams/general.any.js expectedFailures + +The 34 C++ expectedFailures in that WPT file (see +src/wpt/streams-test.ts), each mapped to the suite pin that owns the +divergence root. "Family" means the WPT test fails for the same root a +named suite test pins directly, differing only in incidental asserts. + +| WPT test (abbreviated) | Root | Suite pin | +| --- | --- | --- | +| start() throws an exception | ctor captures sync start throws (ledger #4) | `syncStartThrow` | +| Automatic pull() after start() / after read() / after read(view) | proactive pull (ledger #3) | `pullCountShape` | +| autoAllocateChunkSize | auto-allocated byobRequest on default reads (ledger #5) | `byobRequestOnDefaultRead` | +| Respond to pull() by enqueue() asynchronously / multiple pull() by separate enqueue() / read() twice then enqueue() twice / Push source without pull signal / enqueue()+getReader()+read() | pull-count and coalescing family (ledger #3, #17) | `pullCountShape`, `byteDesiredSizeAccounting` | +| constructor rejects size with type "bytes" | ledger #1 | `sizeStrategyForBytes` | +| cancel() with partially filled pending pull() | done-shape family + partial discard | `cancelWithPartiallyFilledPull` (direct) | +| getReader(), read(view), then cancel() | pull runs before cancel under C++ | `readViewThenCancelOrdering` (direct) | +| enqueue() with Uint16Array then read() / 3 byte + 2-element Uint16Array | mismatched view/enqueue granularity | `readableStreamBytesMismatchedSizes`, `byobUint16Array` | +| read(view) Uint32Array filled by multiple enqueue() | partial fills across enqueues | `byobUint32Array`, `byobPartialRespondMisalignsFillOffset` | +| enqueue(), read(view) partially, then read() | remainder to a default read | `partialViewThenDefaultRead` (direct; PARITY) | +| read(view) Uint16 on close()-d with 1 byte / errored if close()-d before fulfilling read(view) | close-with-partial (ledger #7) | `closeWithPartiallyFilledView` | +| Throwing in pull ignored if errored / pull throw errors stream | pull-throw shapes | `pullThrowIgnoredIfErrored`, `pullThrowErrorsStream` | +| enqueue() discards auto-allocated BYOB request | request invalidation | `enqueueDiscardsByobRequest` | +| releaseLock()+second-reader ×9 (respond / respond(1) Uint16 / respond(3) / respondWithNewView / autoAllocate ×3 / Uint16 respond(1) chains ×2) | the release-relock cluster (ledger #9, #10) | `release-relock.js` (whole module) | +| Multiple read(view): close() and respond() / big enqueue() / multiple enqueue() | multi-pending-read delivery | `readableStreamMultiplePendingReads` | + ## Compatibility flags | Flag | Pinned in main cells | Other cells | @@ -101,3 +126,13 @@ streams-byte-cancel-uaf, streams-byte-handlePush-uaf, streams-byob-close-reentry, streams-byob-concurrent-readatleast, streams-internal-read-buffer-gc, streams-circ-ref-regression, streams-consumer-reentry-gc. + +## IDL shape (deliberately not pinned here) + +WebIDL function metadata — operation `.length` values (optional +arguments do not count), and promise-typed attributes/operations +REJECTING rather than throwing on a broken `this` — is enumerated +per-implementation by WPT's `idlharness.any.js`: the C++ implementation +carries the known deviations as expectedFailures in +`src/wpt/streams-test.ts`; the TypeScript implementation matches spec. +The suites do not duplicate that enumeration. diff --git a/src/tests/streams/readable-byte/byob-reader.js b/src/tests/streams/readable-byte/byob-reader.js index 9affde54e35..9533f634182 100644 --- a/src/tests/streams/readable-byte/byob-reader.js +++ b/src/tests/streams/readable-byte/byob-reader.js @@ -507,3 +507,34 @@ export const byobreaderRegression = { ok(done); }, }; + +// A BYOB read consumes part of an enqueued chunk; a later DEFAULT read +// picks up the remainder (WPT 'enqueue(), read(view) partially, then +// read()'). PARITY: [1,2] to the view, then Uint8Array [3] to the +// default reader. +export const partialViewThenDefaultRead = { + async test() { + const rs = new ReadableStream({ + type: 'bytes', + start(c) { + c.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + const byob = rs.getReader({ mode: 'byob' }); + const first = await byob.read(new Uint8Array(2)); + byob.releaseLock(); + const dflt = rs.getReader(); + const second = await Promise.race([ + dflt + .read() + .then( + (r) => + `second:done=${r.done},type=${r.value?.constructor?.name},bytes=[${r.value ? Array.from(r.value) : ''}]` + ), + scheduler.wait(200).then(() => 'second:pending'), + ]); + strictEqual(first.done, false); + strictEqual(Array.from(first.value).join(','), '1,2'); + strictEqual(second, 'second:done=false,type=Uint8Array,bytes=[3]'); + }, +}; diff --git a/src/tests/streams/readable-byte/controller.js b/src/tests/streams/readable-byte/controller.js index 3bd1e2123b1..12aa5c6919e 100644 --- a/src/tests/streams/readable-byte/controller.js +++ b/src/tests/streams/readable-byte/controller.js @@ -182,3 +182,83 @@ export const closeWithPendingUnfilledByobRead = { await reader.closed; }, }; + +// cancel() while a partially filled pull-into is pending (WPT +// 'cancel() with partially filled pending pull() request'): the read +// resolves done with the partial bytes DISCARDED on both sides — +// DIVERGENCE only in the done shape (C++ an empty view, TypeScript +// undefined; the done-read family). The cancel hook gets the reason +// and the cancel fulfills on both. +export const cancelWithPartiallyFilledPull = { + async test() { + const events = []; + let controller; + const rs = new ReadableStream({ + type: 'bytes', + start(c) { + controller = c; + }, + cancel(reason) { + events.push(`cancel:${reason}`); + }, + }); + const reader = rs.getReader({ mode: 'byob' }); + const readP = reader.read(new Uint16Array(1)); // wants 2 bytes + controller.enqueue(new Uint8Array([0x11])); // partial: 1 byte + await scheduler.wait(1); + const cancelP = reader.cancel('why'); + const read = await Promise.race([ + readP.then( + (r) => + `read:done=${r.done},len=${r.value ? r.value.byteLength : 'undef'}`, + (e) => `read-rejected:${e.name}` + ), + scheduler.wait(200).then(() => 'read:pending'), + ]); + const cancel = await Promise.race([ + cancelP.then( + () => 'cancel:fulfilled', + (e) => `cancel-rejected:${e.name}` + ), + scheduler.wait(200).then(() => 'cancel:pending'), + ]); + strictEqual( + read, + usingTsImpl ? 'read:done=true,len=undef' : 'read:done=true,len=0' + ); + strictEqual(cancel, 'cancel:fulfilled'); + strictEqual(events.join(','), 'cancel:why'); + }, +}; + +// read(view) then immediate cancel() (WPT 'getReader(), read(view), +// then cancel()'): DIVERGENCE — C++ pulls proactively on the read, so +// pull runs BEFORE the cancel hook; TypeScript never pulls (spec: the +// cancel wins). The read resolves done on both. +export const readViewThenCancelOrdering = { + async test() { + const events = []; + const rs = new ReadableStream({ + type: 'bytes', + pull() { + events.push('pull'); + }, + cancel(reason) { + events.push(`cancel:${reason}`); + }, + }); + const reader = rs.getReader({ mode: 'byob' }); + const readP = reader.read(new Uint8Array(4)); + const cancelP = reader.cancel('stop'); + await Promise.all([ + readP.then((r) => events.push(`read:done=${r.done}`)), + cancelP, + ]); + strictEqual( + events.join(','), + usingTsImpl + ? 'cancel:stop,read:done=true' + : 'pull,cancel:stop,read:done=true' + ); + }, +}; diff --git a/src/tests/streams/readable-byte/main.js b/src/tests/streams/readable-byte/main.js index 3b58c6fb8a3..17f61878868 100644 --- a/src/tests/streams/readable-byte/main.js +++ b/src/tests/streams/readable-byte/main.js @@ -51,6 +51,8 @@ export { readAfterCloseReturnsEmptyView, readDetachesCallerBuffer, closeWithPendingUnfilledByobRead, + cancelWithPartiallyFilledPull, + readViewThenCancelOrdering, } from 'controller'; export { @@ -68,6 +70,7 @@ export { readableStreamBytesEnqueueSubarray, readableStreamMultiplePendingReads, byobreaderRegression, + partialViewThenDefaultRead, } from 'byob-reader'; export { diff --git a/src/tests/streams/readable/AGENTS.md b/src/tests/streams/readable/AGENTS.md index 0b92f9feba3..87e6b5f297f 100644 --- a/src/tests/streams/readable/AGENTS.md +++ b/src/tests/streams/readable/AGENTS.md @@ -101,3 +101,13 @@ ResponseTextLargeBody), ts-webstreams-test.js (three parity body tests; its TS-identity assertions remain). streams-js-test.js is deliberately untouched: its tests interleave value and byte sections, so its value halves move when the readable-byte suite consumes the byte halves. + +## IDL shape (deliberately not pinned here) + +WebIDL function metadata — operation `.length` values (optional +arguments do not count), and promise-typed attributes/operations +REJECTING rather than throwing on a broken `this` — is enumerated +per-implementation by WPT's `idlharness.any.js`: the C++ implementation +carries the known deviations as expectedFailures in +`src/wpt/streams-test.ts`; the TypeScript implementation matches spec. +The suites do not duplicate that enumeration. diff --git a/src/tests/streams/transform/AGENTS.md b/src/tests/streams/transform/AGENTS.md index 710c518071a..85ef25bd871 100644 --- a/src/tests/streams/transform/AGENTS.md +++ b/src/tests/streams/transform/AGENTS.md @@ -87,3 +87,13 @@ C++ implementation; `draining-reader.js` asserts both sides. | `draining-reader.js` | TS only (C++ cell asserts the global's absence): writes flow through the transformer into conduit reads; a readable-side backlog plus close sentinel swept in one batch; flush() output rides the final batch; expectedLength undefined; transformer errors propagate | | `data-volumes.js` | volumes through JS transformers with concurrent producer/consumer: 1 MiB passthrough, 8 MiB XOR (proves every byte passed through the transformer), 4096-chunk value mapping | | `which-impl.js` / `helpers.js` | implementation detection; consume helpers | + +## IDL shape (deliberately not pinned here) + +WebIDL function metadata — operation `.length` values (optional +arguments do not count), and promise-typed attributes/operations +REJECTING rather than throwing on a broken `this` — is enumerated +per-implementation by WPT's `idlharness.any.js`: the C++ implementation +carries the known deviations as expectedFailures in +`src/wpt/streams-test.ts`; the TypeScript implementation matches spec. +The suites do not duplicate that enumeration. diff --git a/src/tests/streams/writable/AGENTS.md b/src/tests/streams/writable/AGENTS.md index 6671c4f002b..886b20a85fb 100644 --- a/src/tests/streams/writable/AGENTS.md +++ b/src/tests/streams/writable/AGENTS.md @@ -99,3 +99,13 @@ promises (they resolve with undefined). | `legacy-writer.js` | pre-flag writer semantics (see Compatibility flags) | | `data-volumes.js` | write-side volumes: 4096 × 16 B writes, single 1 MiB write, 1 MiB / 8 MiB chunked with writer.ready honored; the sink verifies the continuous prime-modulus pattern as chunks arrive | | `which-impl.js` | implementation + pedantic detection | + +## IDL shape (deliberately not pinned here) + +WebIDL function metadata — operation `.length` values (optional +arguments do not count), and promise-typed attributes/operations +REJECTING rather than throwing on a broken `this` — is enumerated +per-implementation by WPT's `idlharness.any.js`: the C++ implementation +carries the known deviations as expectedFailures in +`src/wpt/streams-test.ts`; the TypeScript implementation matches spec. +The suites do not duplicate that enumeration. diff --git a/src/tests/streams/writable/abort-semantics.js b/src/tests/streams/writable/abort-semantics.js index 01dd250a6b1..4f992f70cbb 100644 --- a/src/tests/streams/writable/abort-semantics.js +++ b/src/tests/streams/writable/abort-semantics.js @@ -416,3 +416,147 @@ export const errorRaceWithCloseWritable = { strictEqual(await writePromise, undefined); }, }; + +// The WPT aborting residue, part 1: abort() called twice while a write +// is in flight. Both promises fulfill undefined; DIVERGENCE on +// identity — TypeScript returns the SAME pending abort promise for the +// second call (spec), C++ mints a distinct promise. +export const abortTwicePromiseIdentity = { + async test() { + let releaseWrite; + const ws = new WritableStream({ + write() { + return new Promise((resolve) => (releaseWrite = resolve)); + }, + }); + const writer = ws.getWriter(); + writer.write('parked').catch(() => {}); + await scheduler.wait(1); + const first = writer.abort('why'); + const second = writer.abort('why-again'); + strictEqual(first === second, usingTsImpl); + releaseWrite(); + strictEqual(await first, undefined); + strictEqual(await second, undefined); + }, +}; + +// Part 2: an OUTSTANDING (queued, not in-flight) write is rejected when +// abort() runs — the rejection reason is pinned per implementation. +export const abortRejectsOutstandingWriteWithReason = { + async test() { + const reason = new Error('the-reason'); + const ws = new WritableStream({ + write() { + return new Promise(() => {}); + }, + }); + const writer = ws.getWriter(); + writer.write('in-flight').catch(() => {}); + const queued = writer.write('queued'); + const abortP = writer.abort(reason); + const outcome = await Promise.race([ + queued.then( + () => ({ state: 'fulfilled' }), + (e) => ({ state: 'rejected', reason: e }) + ), + scheduler.wait(250).then(() => ({ state: 'pending' })), + ]); + const abortOutcome = await Promise.race([ + abortP.then( + () => 'fulfilled', + () => 'rejected' + ), + scheduler.wait(100).then(() => 'pending'), + ]); + if (usingTsImpl) { + // Spec: the queued write rejects with the very abort reason, and + // the abort settles EAGERLY, not waiting for the parked in-flight + // write. + strictEqual(outcome.state, 'rejected'); + strictEqual(outcome.reason, reason); + strictEqual(abortOutcome, 'fulfilled'); + } else { + // DIVERGENCE (the WPT 'outstanding write() promises' failure): + // C++ leaves the queued write PENDING and the abort itself waits + // on the parked in-flight write forever (both bounded). + strictEqual(outcome.state, 'pending'); + strictEqual(abortOutcome, 'pending'); + } + }, +}; + +// Part 3: writer.abort() then controller.error() with an in-flight +// write that later REJECTS — the settlement order and the stream's +// final error are pinned. +export const abortThenControllerErrorInFlightWrite = { + async test() { + const events = []; + let rejectWrite; + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + write() { + return new Promise((resolve, reject) => (rejectWrite = reject)); + }, + abort(reason) { + events.push(`sink-abort:${reason}`); + }, + }); + const writer = ws.getWriter(); + const write = writer.write('chunk'); + write.catch((e) => events.push(`write-rejected:${e.message}`)); + await scheduler.wait(1); + const abortP = writer.abort('abort-reason'); + abortP.then( + () => events.push('abort-fulfilled'), + (e) => events.push(`abort-rejected:${e.message}`) + ); + controller.error(new Error('controller-error')); + rejectWrite(new Error('write-failure')); + await scheduler.wait(20); + // PARITY: both implementations run the sink's abort hook EAGERLY, + // before the in-flight write settles, then surface the write + // rejection, then fulfill the abort. + strictEqual( + events.join(' | '), + 'sink-abort:abort-reason | write-rejected:write-failure | abort-fulfilled' + ); + }, +}; + +// Part 4: controller.error() FIRST, then writer.abort() with the +// in-flight write finishing normally — sink abort() must NOT run +// (the stream was already erroring before abort was requested). +export const controllerErrorThenAbortInFlightWrite = { + async test() { + const events = []; + let resolveWrite; + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + write() { + return new Promise((resolve) => (resolveWrite = resolve)); + }, + abort() { + events.push('sink-abort'); + }, + }); + const writer = ws.getWriter(); + writer.write('chunk').catch(() => events.push('write-rejected')); + await scheduler.wait(1); + controller.error(new Error('controller-error')); + const abortP = writer.abort('late-abort'); + abortP.then( + () => events.push('abort-fulfilled'), + (e) => events.push(`abort-rejected:${e.message}`) + ); + resolveWrite(); + await scheduler.wait(20); + strictEqual(events.join(' | '), 'abort-rejected:controller-error'); + }, +}; diff --git a/src/tests/streams/writable/main.js b/src/tests/streams/writable/main.js index a2247cba07a..ab5df58fc50 100644 --- a/src/tests/streams/writable/main.js +++ b/src/tests/streams/writable/main.js @@ -72,6 +72,10 @@ export { writableStreamSinkAlgNoCallErrorBeforeAbort, writableStreamWriterWithPendingAbort, errorRaceWithCloseWritable, + abortTwicePromiseIdentity, + abortRejectsOutstandingWriteWithReason, + abortThenControllerErrorInFlightWrite, + controllerErrorThenAbortInFlightWrite, } from 'abort-semantics'; export {