From 4d1bf5f1d2c7c9928b61a12eafd3249adce52e6a Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 13:43:44 +0000 Subject: [PATCH 1/5] Pin a byte source's expectedLength as an exact total DUP-23 step 1, test only. Under TypeScript a byte source that declares expectedLength must deliver exactly that many bytes through enqueue(), respond() and respondWithNewView() combined: the delivery that goes past the total throws RangeError and errors the stream, and so does a close() short of it. No test reached either error, so the accounting on any of those paths could go without a test failing. byteSourceExpectedLengthContract pins an overflow through each of the three, the exact total followed by close(), and a close() short of the total. C++ enforces neither error: every delivery reaches the reader, and close() closes the stream. Readable-byte ledger #42 records the divergence. --- src/tests/streams/readable-byte/AGENTS.md | 3 +- .../streams/readable-byte/expected-length.js | 91 ++++++++++++++++++- src/tests/streams/readable-byte/main.js | 1 + 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/tests/streams/readable-byte/AGENTS.md b/src/tests/streams/readable-byte/AGENTS.md index cacdb6c955a..aeeaf1bfd01 100644 --- a/src/tests/streams/readable-byte/AGENTS.md +++ b/src/tests/streams/readable-byte/AGENTS.md @@ -56,6 +56,7 @@ behavior-parity (messages aside). | 39 | ctor argument conversion order, every member of a byte source (the readable suite's #2 for a default source) | the source first, every member in its own order (type, autoAllocateChunkSize, start, pull, cancel, expectedLength), then the strategy: hwm and its ToNumber, size | the strategy first (spec); then the source's members in lexicographic order (spec: autoAllocateChunkSize, cancel, expectedLength, pull, start, type) | `argumentConversionOrder` | | 40 | which error wins when two are possible (the readable suite's #32; in both, spec: every source member converts before the strategy and a zero autoAllocateChunkSize are checked, so a non-callable pull, TypeError with different messages, beats an invalid highWaterMark, a size function and a zero autoAllocateChunkSize; a zero autoAllocateChunkSize is checked after an invalid highWaterMark, whose error wins, TypeError in C++ and RangeError in TypeScript) | a non-callable pull also beats a negative autoAllocateChunkSize, which the int conversion accepts and a later range check rejects | a negative autoAllocateChunkSize (TypeError) beats a non-callable pull (spec: it converts first) | `conversionErrorPrecedence` | | 41 | a bare ArrayBuffer passed to enqueue() or respondWithNewView(), whose WebIDL parameter is an ArrayBufferView (any other non-view there, and every non-view at read(view) and readAtLeast(), is a TypeError in both, messages aside) | taken as a view over the whole buffer (the parameter is a BufferSource): enqueue() detaches it and delivers its bytes; respondWithNewView() accepts one of the request's buffer length, detaches it and delivers its bytes | TypeError 'chunk must be an ArrayBufferView' or 'view must be an ArrayBufferView' (spec); the buffer stays attached, nothing is enqueued, and the pending read is left for a later response | `nonViewArgumentsRejected` | +| 42 | a byte source's declared `expectedLength` as an exact total of the bytes it delivers through enqueue(), respond() and respondWithNewView() (delivering exactly the total, then close(), is parity) | not enforced: a delivery past the total reaches the reader, and close() short of it closes the stream | the delivery past the total throws RangeError 'byte source delivered more bytes than its declared expectedLength', and close() short of it RangeError 'byte source closed before producing its declared expectedLength'; either errors the stream with that error | `byteSourceExpectedLengthContract` | Parity worth noting (probed, pinned): byte hwm defaults to 0 with NO automatic pull; pull-throw and error-then-throw identity; enqueue @@ -145,7 +146,7 @@ named suite test pins directly, differing only in incidental asserts. | `draining-reader.js` | TS only (C++ cell asserts the global's absence): a queued byte backlog plus the close sentinel swept in one batched read with chunks INTACT (no coalescing); a read whose maxSize runs out at the last bytes before the close reaches the close all the same, reporting done and closing the stream in that call; with autoAllocateChunkSize the conduit's wait-read synthesizes the descriptor so pull carries a byobRequest (ledger #6); expectedLength undefined when undeclared, a declared one passed through as the exact bigint (above 2^53 and up to the uint64 maximum); error/cancel propagation | | `data-volumes.js` | byte-transfer volumes 64 B / 64 KiB / 1 MiB / 8 MiB via default and BYOB readers (incl. mismatched view/enqueue granularity), continuous prime-modulus pattern verified byte-exact; the source closes WITH its last enqueue (single-shape loops; parked-read close settlement is ledger #19's) | | `pollution.js` | prototype pollution neither implementation observes: a patched byte controller error() still errors the stream; readAtLeast() ignores a patched read(); tee copies and respond() remainders never consult the ArrayBuffer or %TypedArray% species; a native body ignores Object.prototype `type`/`autoAllocateChunkSize`; one Object.prototype.then getter fire per read, on an ordinary result object (default reads, autoAllocateChunkSize, BYOB and readAtLeast, at close, native bodies); ledger #34 | -| `expected-length.js` | ledger #35, with the converted length observed as a fetch body's Content-Length via SELF; integral lengths up to the uint64 maximum accepted | +| `expected-length.js` | ledger #35, with the converted length observed as a fetch body's Content-Length via SELF; integral lengths up to the uint64 maximum accepted; ledger #42, the declared length as an exact total through enqueue(), respond() and respondWithNewView() | Consumed sources (deleted): streams-js-test.js (value halves were already covered by the readable suite), streams-tee-edge-cases-test.js, diff --git a/src/tests/streams/readable-byte/expected-length.js b/src/tests/streams/readable-byte/expected-length.js index e08c0085ef7..d1cdebc6df9 100644 --- a/src/tests/streams/readable-byte/expected-length.js +++ b/src/tests/streams/readable-byte/expected-length.js @@ -14,8 +14,15 @@ // 0. Every failure is a TypeError. C++ lets the number 2 ** 64 through its // range check to a cast whose result is undefined, so that one value is // asserted for TypeScript only. +// +// DIVERGENCE (ledger #42): TS also holds the source to the declared length +// as an exact total of what it delivers through enqueue(), respond() and +// respondWithNewView(). The delivery that goes past it throws RangeError, +// and so does a close() short of it; either errors the stream with that +// error. C++ enforces neither: every delivery reaches the reader, and +// close() closes the stream. -import { ok, strictEqual, throws } from 'node:assert'; +import { ok, rejects, strictEqual, throws } from 'node:assert'; import { usingTsImpl } from 'which-impl'; function byteSource(expectedLength, byteCount = 0) { @@ -109,3 +116,85 @@ export const byteSourceExpectedLengthRange = { if (usingTsImpl) throws(() => byteSource(2 ** 64), TypeError); }, }; + +// A byte source declaring expectedLength, with a read(view) of 8 bytes +// pending, so that its controller holds a byobRequest. +async function pendingByobRead(expectedLength) { + let controller; + const rs = new ReadableStream({ + type: 'bytes', + expectedLength, + start(c) { + controller = c; + }, + }); + const reader = rs.getReader({ mode: 'byob' }); + const read = reader.read(new Uint8Array(8)); + await scheduler.wait(5); + return { controller, reader, read }; +} + +// DIVERGENCE (ledger #42): the declared length as an exact total, which +// TS enforces and C++ does not. +export const byteSourceExpectedLengthContract = { + async test() { + const overflow = + 'byte source delivered more bytes than its declared expectedLength'; + const deliverFive = { + 'respond()': (c) => c.byobRequest.respond(5), + 'respondWithNewView()': (c) => { + const { buffer, byteOffset } = c.byobRequest.view; + c.byobRequest.respondWithNewView(new Uint8Array(buffer, byteOffset, 5)); + }, + 'enqueue()': (c) => c.enqueue(new Uint8Array(5)), + }; + for (const [name, deliver] of Object.entries(deliverFive)) { + const { controller, reader, read } = await pendingByobRead(4); + if (usingTsImpl) { + throws( + () => deliver(controller), + { name: 'RangeError', message: overflow }, + name + ); + await rejects(read, { name: 'RangeError', message: overflow }); + await rejects(reader.closed, { name: 'RangeError', message: overflow }); + } else { + deliver(controller); + strictEqual((await read).value.byteLength, 5, name); + await reader.cancel('cleanup'); + } + } + + // Exactly the declared total, then close(): parity. + { + const { controller, reader, read } = await pendingByobRead(4); + controller.byobRequest.respond(4); + controller.close(); + strictEqual((await read).value.byteLength, 4); + await reader.closed; + } + + // close() short of the declared total. + { + const { controller, reader, read } = await pendingByobRead(4); + controller.byobRequest.respond(2); + if (usingTsImpl) { + const underflow = + 'byte source closed before producing its declared expectedLength'; + throws(() => controller.close(), { + name: 'RangeError', + message: underflow, + }); + strictEqual((await read).value.byteLength, 2); + await rejects(reader.closed, { + name: 'RangeError', + message: underflow, + }); + } else { + controller.close(); + strictEqual((await read).value.byteLength, 2); + await reader.closed; + } + } + }, +}; diff --git a/src/tests/streams/readable-byte/main.js b/src/tests/streams/readable-byte/main.js index 02981942bef..e7bb39c03a0 100644 --- a/src/tests/streams/readable-byte/main.js +++ b/src/tests/streams/readable-byte/main.js @@ -279,4 +279,5 @@ export { byteSourceExpectedLengthRejected, byteSourceExpectedLengthCoercion, byteSourceExpectedLengthRange, + byteSourceExpectedLengthContract, } from 'expected-length'; From b4427b6dd587c900b6320be2938d7353e5cd7e9e Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 13:46:48 +0000 Subject: [PATCH 2/5] Share the byte controller's respond tail DUP-23 step 2. respond() and respondWithNewView() ended with the same steps once they had validated the response and transferred the head pull-into's buffer: invalidate the byobRequest, then answer the released head, commit the pending pull-intos when the stream is closed, or count the bytes toward expectedLength, fill the head, and check for close and pull. The two copies differed only in where the byte count came from. #respondInternal, named for the spec's ReadableByteStreamControllerRespondInternal, now holds those steps. Each caller keeps its own validation and buffer transfer. Nothing observable changes. Dropping the accounting call from #respondInternal fails only byteSourceExpectedLengthContract, the step 1 pin. --- src/per_isolate/webstreams/readable.ts | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/per_isolate/webstreams/readable.ts b/src/per_isolate/webstreams/readable.ts index dd631f6e9c1..c98069074e2 100644 --- a/src/per_isolate/webstreams/readable.ts +++ b/src/per_isolate/webstreams/readable.ts @@ -2445,22 +2445,7 @@ class ReadableByteStreamController implements ReadableByteStreamControllerType { if (!head.settledAtEndOfData) { head.buffer = ArrayBufferPrototypeTransferToFixedLength(head.buffer); } - this.#invalidateByobRequest(); - if (cursor === undefined) { - this.#respondToReleasedHead(bytesWritten, state); - } else if (state === 'closed') { - // respond(0)-while-closed: commit all pending descriptors with - // { done: true, value: filled-so-far view }. - cursor.commitPullIntosOnClose(); - } else { - // EXPECTED-LENGTH CONTRACT: respond() bytes count toward the total - // (the second ingress path alongside enqueue). - this.#accountDelivery(bytesWritten); - cursor.respondBYOB(bytesWritten); - // The respond may have drained the queue with close requested. - this.#core.maybeCloseStream(); - this.#core.callPullIfNeeded(); - } + this.#respondInternal(cursor, bytesWritten, state); } // Exact-total accounting (see #expectedLength). On overflow (more bytes @@ -2534,15 +2519,31 @@ class ReadableByteStreamController implements ReadableByteStreamControllerType { if (!head.settledAtEndOfData) { head.buffer = ArrayBufferPrototypeTransferToFixedLength(info.buffer); } + this.#respondInternal(cursor, info.byteLength, state); + } + + // Spec ReadableByteStreamControllerRespondInternal: respond() and + // respondWithNewView() once they have validated the response and + // transferred the head pull-into's buffer. `cursor` is the sole cursor + // whose head was answered, or undefined for #releasedHead. + #respondInternal( + cursor: ByteStreamCursorType | undefined, + bytesWritten: number, + state: ReadableStreamState + ): void { this.#invalidateByobRequest(); if (cursor === undefined) { - this.#respondToReleasedHead(info.byteLength, state); + this.#respondToReleasedHead(bytesWritten, state); } else if (state === 'closed') { + // A closed-state response (respond(0), or a zero-length view): + // commit all pending descriptors with { done: true, value: + // filled-so-far view }. cursor.commitPullIntosOnClose(); } else { - // EXPECTED-LENGTH CONTRACT: counts toward the total like respond(). - this.#accountDelivery(info.byteLength); - cursor.respondBYOB(info.byteLength); + // EXPECTED-LENGTH CONTRACT: responded bytes count toward the total + // (the second ingress path alongside enqueue). + this.#accountDelivery(bytesWritten); + cursor.respondBYOB(bytesWritten); // The respond may have drained the queue with close requested. this.#core.maybeCloseStream(); this.#core.callPullIfNeeded(); From d86ab02b69561d9b16e318346ca4c4243b3e93c0 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 14:03:32 +0000 Subject: [PATCH 3/5] Pin expectedLength through a byobRequest held across tee() DUP-23 step 3, test only. When a reader releases a pending read(view) whose byobRequest the source holds, and the stream is then teed, the request survives the fork. TypeScript answers it on a path of its own, which counts the responded bytes toward expectedLength separately. No test reached that count: every response in byteSourceExpectedLengthContract answers a reader's own read. byteSourceExpectedLengthHeldRequest pins an overflow through respond() and respondWithNewView() on such a request, which errors both branches under TypeScript and reaches both under C++, and the exact total followed by close(), which closes both branches in both. Dropping the count from that path fails only this test. The overflow error and the two responses are now shared with byteSourceExpectedLengthContract. Readable-byte ledger #42 covers the held request and records TypeScript's errors as by design. --- src/tests/streams/readable-byte/AGENTS.md | 4 +- .../streams/readable-byte/expected-length.js | 90 +++++++++++++++---- src/tests/streams/readable-byte/main.js | 1 + 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/tests/streams/readable-byte/AGENTS.md b/src/tests/streams/readable-byte/AGENTS.md index aeeaf1bfd01..c2ffa99ef36 100644 --- a/src/tests/streams/readable-byte/AGENTS.md +++ b/src/tests/streams/readable-byte/AGENTS.md @@ -56,7 +56,7 @@ behavior-parity (messages aside). | 39 | ctor argument conversion order, every member of a byte source (the readable suite's #2 for a default source) | the source first, every member in its own order (type, autoAllocateChunkSize, start, pull, cancel, expectedLength), then the strategy: hwm and its ToNumber, size | the strategy first (spec); then the source's members in lexicographic order (spec: autoAllocateChunkSize, cancel, expectedLength, pull, start, type) | `argumentConversionOrder` | | 40 | which error wins when two are possible (the readable suite's #32; in both, spec: every source member converts before the strategy and a zero autoAllocateChunkSize are checked, so a non-callable pull, TypeError with different messages, beats an invalid highWaterMark, a size function and a zero autoAllocateChunkSize; a zero autoAllocateChunkSize is checked after an invalid highWaterMark, whose error wins, TypeError in C++ and RangeError in TypeScript) | a non-callable pull also beats a negative autoAllocateChunkSize, which the int conversion accepts and a later range check rejects | a negative autoAllocateChunkSize (TypeError) beats a non-callable pull (spec: it converts first) | `conversionErrorPrecedence` | | 41 | a bare ArrayBuffer passed to enqueue() or respondWithNewView(), whose WebIDL parameter is an ArrayBufferView (any other non-view there, and every non-view at read(view) and readAtLeast(), is a TypeError in both, messages aside) | taken as a view over the whole buffer (the parameter is a BufferSource): enqueue() detaches it and delivers its bytes; respondWithNewView() accepts one of the request's buffer length, detaches it and delivers its bytes | TypeError 'chunk must be an ArrayBufferView' or 'view must be an ArrayBufferView' (spec); the buffer stays attached, nothing is enqueued, and the pending read is left for a later response | `nonViewArgumentsRejected` | -| 42 | a byte source's declared `expectedLength` as an exact total of the bytes it delivers through enqueue(), respond() and respondWithNewView() (delivering exactly the total, then close(), is parity) | not enforced: a delivery past the total reaches the reader, and close() short of it closes the stream | the delivery past the total throws RangeError 'byte source delivered more bytes than its declared expectedLength', and close() short of it RangeError 'byte source closed before producing its declared expectedLength'; either errors the stream with that error | `byteSourceExpectedLengthContract` | +| 42 | a byte source's declared `expectedLength` as an exact total of the bytes it delivers through enqueue(), respond() and respondWithNewView(), a response to a byobRequest held across tee() (#25) included (delivering exactly the total, then close(), is parity) | not enforced: a delivery past the total reaches the reader, and close() short of it closes the stream | by design: the delivery past the total throws RangeError 'byte source delivered more bytes than its declared expectedLength', and close() short of it RangeError 'byte source closed before producing its declared expectedLength'; either errors the stream with that error | `byteSourceExpectedLengthContract`, `byteSourceExpectedLengthHeldRequest` | Parity worth noting (probed, pinned): byte hwm defaults to 0 with NO automatic pull; pull-throw and error-then-throw identity; enqueue @@ -146,7 +146,7 @@ named suite test pins directly, differing only in incidental asserts. | `draining-reader.js` | TS only (C++ cell asserts the global's absence): a queued byte backlog plus the close sentinel swept in one batched read with chunks INTACT (no coalescing); a read whose maxSize runs out at the last bytes before the close reaches the close all the same, reporting done and closing the stream in that call; with autoAllocateChunkSize the conduit's wait-read synthesizes the descriptor so pull carries a byobRequest (ledger #6); expectedLength undefined when undeclared, a declared one passed through as the exact bigint (above 2^53 and up to the uint64 maximum); error/cancel propagation | | `data-volumes.js` | byte-transfer volumes 64 B / 64 KiB / 1 MiB / 8 MiB via default and BYOB readers (incl. mismatched view/enqueue granularity), continuous prime-modulus pattern verified byte-exact; the source closes WITH its last enqueue (single-shape loops; parked-read close settlement is ledger #19's) | | `pollution.js` | prototype pollution neither implementation observes: a patched byte controller error() still errors the stream; readAtLeast() ignores a patched read(); tee copies and respond() remainders never consult the ArrayBuffer or %TypedArray% species; a native body ignores Object.prototype `type`/`autoAllocateChunkSize`; one Object.prototype.then getter fire per read, on an ordinary result object (default reads, autoAllocateChunkSize, BYOB and readAtLeast, at close, native bodies); ledger #34 | -| `expected-length.js` | ledger #35, with the converted length observed as a fetch body's Content-Length via SELF; integral lengths up to the uint64 maximum accepted; ledger #42, the declared length as an exact total through enqueue(), respond() and respondWithNewView() | +| `expected-length.js` | ledger #35, with the converted length observed as a fetch body's Content-Length via SELF; integral lengths up to the uint64 maximum accepted; ledger #42, the declared length as an exact total through enqueue(), respond() and respondWithNewView(), and through a byobRequest held across tee() | Consumed sources (deleted): streams-js-test.js (value halves were already covered by the readable suite), streams-tee-edge-cases-test.js, diff --git a/src/tests/streams/readable-byte/expected-length.js b/src/tests/streams/readable-byte/expected-length.js index d1cdebc6df9..fd39a4592b3 100644 --- a/src/tests/streams/readable-byte/expected-length.js +++ b/src/tests/streams/readable-byte/expected-length.js @@ -15,9 +15,10 @@ // range check to a cast whose result is undefined, so that one value is // asserted for TypeScript only. // -// DIVERGENCE (ledger #42): TS also holds the source to the declared length -// as an exact total of what it delivers through enqueue(), respond() and -// respondWithNewView(). The delivery that goes past it throws RangeError, +// DIVERGENCE (ledger #42), by design: TS also holds the source to the +// declared length as an exact total of what it delivers through enqueue(), +// respond() and respondWithNewView(), a response to a byobRequest held +// across tee() included. The delivery that goes past it throws RangeError, // and so does a close() short of it; either errors the stream with that // error. C++ enforces neither: every delivery reaches the reader, and // close() closes the stream. @@ -131,33 +132,38 @@ async function pendingByobRead(expectedLength) { const reader = rs.getReader({ mode: 'byob' }); const read = reader.read(new Uint8Array(8)); await scheduler.wait(5); - return { controller, reader, read }; + return { rs, controller, reader, read }; } +const OVERFLOW = { + name: 'RangeError', + message: 'byte source delivered more bytes than its declared expectedLength', +}; + +// Five bytes, one past an expectedLength of 4, through each way of +// answering the byobRequest. +const respondFive = { + 'respond()': (c) => c.byobRequest.respond(5), + 'respondWithNewView()': (c) => { + const { buffer, byteOffset } = c.byobRequest.view; + c.byobRequest.respondWithNewView(new Uint8Array(buffer, byteOffset, 5)); + }, +}; + // DIVERGENCE (ledger #42): the declared length as an exact total, which // TS enforces and C++ does not. export const byteSourceExpectedLengthContract = { async test() { - const overflow = - 'byte source delivered more bytes than its declared expectedLength'; const deliverFive = { - 'respond()': (c) => c.byobRequest.respond(5), - 'respondWithNewView()': (c) => { - const { buffer, byteOffset } = c.byobRequest.view; - c.byobRequest.respondWithNewView(new Uint8Array(buffer, byteOffset, 5)); - }, + ...respondFive, 'enqueue()': (c) => c.enqueue(new Uint8Array(5)), }; for (const [name, deliver] of Object.entries(deliverFive)) { const { controller, reader, read } = await pendingByobRead(4); if (usingTsImpl) { - throws( - () => deliver(controller), - { name: 'RangeError', message: overflow }, - name - ); - await rejects(read, { name: 'RangeError', message: overflow }); - await rejects(reader.closed, { name: 'RangeError', message: overflow }); + throws(() => deliver(controller), OVERFLOW, name); + await rejects(read, OVERFLOW); + await rejects(reader.closed, OVERFLOW); } else { deliver(controller); strictEqual((await read).value.byteLength, 5, name); @@ -198,3 +204,51 @@ export const byteSourceExpectedLengthContract = { } }, }; + +// pendingByobRead's source with its reader released, the source holding +// the byobRequest over the released read, then teed: the request survives +// the fork, and a response to it reaches both branches (ledger #25). +async function teeWithHeldByobRequest(expectedLength) { + const { rs, controller, reader, read } = + await pendingByobRead(expectedLength); + const request = controller.byobRequest; + reader.releaseLock(); + await rejects(read, TypeError); + const branches = rs.tee(); + strictEqual(controller.byobRequest, request); + return { controller, branches }; +} + +// DIVERGENCE (ledger #42) through a byobRequest held across tee(): the +// response that goes past the declared total errors both branches under +// TS and reaches both under C++. +export const byteSourceExpectedLengthHeldRequest = { + async test() { + for (const [name, deliver] of Object.entries(respondFive)) { + const { controller, branches } = await teeWithHeldByobRequest(4); + if (usingTsImpl) { + throws(() => deliver(controller), OVERFLOW, name); + for (const branch of branches) { + await rejects(branch.getReader().read(), OVERFLOW); + } + } else { + deliver(controller); + for (const branch of branches) { + const { value } = await branch.getReader().read(); + strictEqual(value.byteLength, 5, name); + } + controller.error(new Error('cleanup')); + } + } + + // Exactly the declared total, then close(): parity. + const { controller, branches } = await teeWithHeldByobRequest(4); + controller.byobRequest.respond(4); + controller.close(); + for (const branch of branches) { + const reader = branch.getReader(); + strictEqual((await reader.read()).value.byteLength, 4); + strictEqual((await reader.read()).done, true); + } + }, +}; diff --git a/src/tests/streams/readable-byte/main.js b/src/tests/streams/readable-byte/main.js index e7bb39c03a0..c6aaf50c032 100644 --- a/src/tests/streams/readable-byte/main.js +++ b/src/tests/streams/readable-byte/main.js @@ -280,4 +280,5 @@ export { byteSourceExpectedLengthCoercion, byteSourceExpectedLengthRange, byteSourceExpectedLengthContract, + byteSourceExpectedLengthHeldRequest, } from 'expected-length'; From 8f1af885a2bc1770ec2de09827d699b9140194be Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 19:38:29 +0000 Subject: [PATCH 4/5] Point the expectedLength pins at ledger #42 alone DUP-23 step 4, test only. The comments above byteSourceExpectedLengthContract and byteSourceExpectedLengthHeldRequest each restated the divergence, which the file header and readable-byte ledger #42 already describe. Each is now `// DIVERGENCE (ledger #42)`. --- src/tests/streams/readable-byte/expected-length.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tests/streams/readable-byte/expected-length.js b/src/tests/streams/readable-byte/expected-length.js index fd39a4592b3..4880056774f 100644 --- a/src/tests/streams/readable-byte/expected-length.js +++ b/src/tests/streams/readable-byte/expected-length.js @@ -150,8 +150,7 @@ const respondFive = { }, }; -// DIVERGENCE (ledger #42): the declared length as an exact total, which -// TS enforces and C++ does not. +// DIVERGENCE (ledger #42) export const byteSourceExpectedLengthContract = { async test() { const deliverFive = { @@ -219,9 +218,7 @@ async function teeWithHeldByobRequest(expectedLength) { return { controller, branches }; } -// DIVERGENCE (ledger #42) through a byobRequest held across tee(): the -// response that goes past the declared total errors both branches under -// TS and reaches both under C++. +// DIVERGENCE (ledger #42) export const byteSourceExpectedLengthHeldRequest = { async test() { for (const [name, deliver] of Object.entries(respondFive)) { From a5970ca544d7e94305797175ae5321daf80726ea Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 19:39:15 +0000 Subject: [PATCH 5/5] Pin close() short of expectedLength through a held byobRequest DUP-23 step 5, test only. byteSourceExpectedLengthHeldRequest pinned an overflow and the exact total through a byobRequest held across tee(), but not a close() short of the total, which byteSourceExpectedLengthContract pins on a reader's own read. It now also responds with two bytes of four and closes: under TypeScript close() throws RangeError and both branches error with it, and under C++ both branches read the two bytes and close. The close() check is the one the Contract case reaches. The underflow error is now shared by both tests, as the overflow error is. --- .../streams/readable-byte/expected-length.js | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/tests/streams/readable-byte/expected-length.js b/src/tests/streams/readable-byte/expected-length.js index 4880056774f..018886749ae 100644 --- a/src/tests/streams/readable-byte/expected-length.js +++ b/src/tests/streams/readable-byte/expected-length.js @@ -140,6 +140,11 @@ const OVERFLOW = { message: 'byte source delivered more bytes than its declared expectedLength', }; +const UNDERFLOW = { + name: 'RangeError', + message: 'byte source closed before producing its declared expectedLength', +}; + // Five bytes, one past an expectedLength of 4, through each way of // answering the byobRequest. const respondFive = { @@ -184,17 +189,9 @@ export const byteSourceExpectedLengthContract = { const { controller, reader, read } = await pendingByobRead(4); controller.byobRequest.respond(2); if (usingTsImpl) { - const underflow = - 'byte source closed before producing its declared expectedLength'; - throws(() => controller.close(), { - name: 'RangeError', - message: underflow, - }); + throws(() => controller.close(), UNDERFLOW); strictEqual((await read).value.byteLength, 2); - await rejects(reader.closed, { - name: 'RangeError', - message: underflow, - }); + await rejects(reader.closed, UNDERFLOW); } else { controller.close(); strictEqual((await read).value.byteLength, 2); @@ -239,13 +236,34 @@ export const byteSourceExpectedLengthHeldRequest = { } // Exactly the declared total, then close(): parity. - const { controller, branches } = await teeWithHeldByobRequest(4); - controller.byobRequest.respond(4); - controller.close(); - for (const branch of branches) { - const reader = branch.getReader(); - strictEqual((await reader.read()).value.byteLength, 4); - strictEqual((await reader.read()).done, true); + { + const { controller, branches } = await teeWithHeldByobRequest(4); + controller.byobRequest.respond(4); + controller.close(); + for (const branch of branches) { + const reader = branch.getReader(); + strictEqual((await reader.read()).value.byteLength, 4); + strictEqual((await reader.read()).done, true); + } + } + + // close() short of the declared total. + { + const { controller, branches } = await teeWithHeldByobRequest(4); + controller.byobRequest.respond(2); + if (usingTsImpl) { + throws(() => controller.close(), UNDERFLOW); + for (const branch of branches) { + await rejects(branch.getReader().read(), UNDERFLOW); + } + } else { + controller.close(); + for (const branch of branches) { + const reader = branch.getReader(); + strictEqual((await reader.read()).value.byteLength, 2); + strictEqual((await reader.read()).done, true); + } + } } }, };