From 5a7fc7225b026bd593e52c8a87fdccdb1020169a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 08:33:55 -0500 Subject: [PATCH] Support stream/consumers arrayBuffer - Support `arrayBuffer()` from `node:stream/consumers` - Print ArrayBuffer and SharedArrayBuffer values like Node - Mark the stream consumers as partially supported in the compatibility data --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 30 +++---- .../generated/node-v24-backlog.json | 63 +++++++++------ .../generated/node-v24-internal.json | 40 ++++++--- internal/compatibility/static-support.json | 2 + .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/lower-stream.ts | 25 +++--- .../src/frontend/lowering/surfaces.ts | 9 +-- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 4 +- packages/compiler/surface-manifest.json | 8 -- ...4870-stream-consumers-array-buffer.ts.json | 6 ++ .../corpus/4871-array-buffer-inspect.ts.json | 6 ++ packages/runtime/src/scr_inspect.c | 58 +++++++++++++ packages/runtime/src/scr_runtime.h | 8 +- packages/runtime/src/scr_stream.c | 21 ++++- .../4870-stream-consumers-array-buffer.ts | 81 +++++++++++++++++++ tests/corpus/4871-array-buffer-inspect.ts | 27 +++++++ 18 files changed, 309 insertions(+), 84 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-stream-consumers-array-buffer.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-buffer-inspect.ts.json create mode 100644 tests/corpus/4870-stream-consumers-array-buffer.ts create mode 100644 tests/corpus/4871-array-buffer-inspect.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index e6420817b..4fb8a4296 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "32dbd982601194038ff9", + "artifactVersion": "a3c8ef2ce07eb09a4739", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 55f31b67e..bec550ab9 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -854,9 +854,9 @@ "apiEntries": 88, "static": { "supported": 0, - "partial": 3, - "refused": 2, - "not-implemented": 83, + "partial": 7, + "refused": 1, + "not-implemented": 80, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -76400,9 +76400,9 @@ "inherited": true }, "static": { - "status": "refused", - "detail": "Explicitly rejected by a named compiler diagnostic.", - "verification": "explicit-refusal" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" } }, { @@ -76446,9 +76446,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" } }, { @@ -76492,9 +76492,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" } }, { @@ -76515,9 +76515,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index f5f6f75a0..455df0df6 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,9 +7,9 @@ "tierItemCount": 3288, "tiers": { "static": { - "replace-refusal": 42, - "verify-gap": 2417, - "audit-partial": 747, + "replace-refusal": 41, + "verify-gap": 2414, + "audit-partial": 751, "classify": 2, "implement": 80 } @@ -76094,12 +76094,14 @@ "priority": "high", "tiers": { "static": { - "status": "refused", - "action": "replace-refusal", - "verification": "explicit-refusal", + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", "confidence": "high", - "source": "surface-manifest:unsupported:node-builtin.stream.consumers.arrayBuffer", - "tests": [] + "source": "compiler-feature:webstreams.streamConsumers.arrayBuffer", + "tests": [ + "tests/corpus/4870-stream-consumers-array-buffer.ts" + ] } } }, @@ -76144,12 +76146,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:webstreams", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:webstreams.streamConsumers.buffer", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] } } }, @@ -76194,12 +76199,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:webstreams", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:webstreams.streamConsumers.json", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] } } }, @@ -76219,12 +76227,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:webstreams", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:webstreams.streamConsumers.text", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index dcda1441d..f876a4eb3 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -911,9 +911,9 @@ "apiEntries": 88, "static": { "supported": 0, - "partial": 3, - "refused": 2, - "not-implemented": 83, + "partial": 7, + "refused": 1, + "not-implemented": 80, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -101671,7 +101671,10 @@ "status": "partial", "evidence": "derived:descendants", "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts", "tests/corpus/2862-web-readable-async-iteration.ts", + "tests/corpus/4870-stream-consumers-array-buffer.ts", "tests/harness/fetch-conformance.test.ts" ] }, @@ -101787,8 +101790,11 @@ "inherited": true }, "static": { - "status": "refused", - "evidence": "surface-manifest:unsupported:node-builtin.stream.consumers.arrayBuffer" + "status": "partial", + "evidence": "compiler-feature:webstreams.streamConsumers.arrayBuffer", + "tests": [ + "tests/corpus/4870-stream-consumers-array-buffer.ts" + ] }, "anchorSource": "exact" }, @@ -101833,8 +101839,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webstreams" + "status": "partial", + "evidence": "compiler-feature:webstreams.streamConsumers.buffer", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] }, "anchorSource": "exact" }, @@ -101879,8 +101889,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webstreams" + "status": "partial", + "evidence": "compiler-feature:webstreams.streamConsumers.json", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] }, "anchorSource": "exact" }, @@ -101902,8 +101916,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webstreams" + "status": "partial", + "evidence": "compiler-feature:webstreams.streamConsumers.text", + "tests": [ + "tests/corpus/2629-stream-consumers.ts", + "tests/corpus/2630-stream-consumers-errors.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 3f5ecefff..3da12965b 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -224,6 +224,8 @@ { "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] }, { "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] }, { "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] }, + { "chapter": "webstreams", "signatures": ["`streamConsumers.text(stream)`", "`streamConsumers.json(stream)`", "`streamConsumers.buffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/2629-stream-consumers.ts", "tests/corpus/2630-stream-consumers-errors.ts"] }, + { "chapter": "webstreams", "signatures": ["`streamConsumers.arrayBuffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/4870-stream-consumers-array-buffer.ts"] }, { "chapter": "url", "symbols": ["url.fileURLToPath", "url.fileURLToPathBuffer", "url.pathToFileURL"], "status": "partial", "evidence": ["tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js", "tests/fixtures/node-types/file-url-conversion.ts"] }, { "chapter": "url", "symbols": ["URL.canParse", "URL.parse", "url.toString", "url.toJSON"], "status": "partial", "evidence": ["tests/corpus/node24-url-factories.ts", "tests/corpus/node24-url-factory-errors.ts", "tests/fixtures/node-types/url-factories.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "url", "signatures": ["Class: `URL`"], "status": "partial", "evidence": ["tests/corpus/url-runtime-base.ts", "tests/corpus/node24-url-mutation.ts", "tests/corpus/node24-url-mutation-coercion.js", "tests/fixtures/node-types/url-mutation.ts"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 667ddb7d5..6d15e6e70 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -938,6 +938,7 @@ export const LIB_FN_SYMS: Record = { "sc.text": "scr_sc_text", "sc.json": "scr_sc_json", "sc.buffer": "scr_sc_buffer", + "sc.arrayBuffer": "scr_sc_array_buffer", "dc.channel": "scr_dc_channel", "dc.subscribe": "scr_dc_subscribe", "dc.unsubscribe": "scr_dc_unsubscribe", @@ -1139,6 +1140,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "sc.text", "sc.json", "sc.buffer", + "sc.arrayBuffer", "net.listen", "net.listenCb", "net.listenOpts", diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index cd38c56ae..9170252f8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -1635,15 +1635,20 @@ export function lowerStreamModuleCall( } if (bi.module === "stream/consumers") { const args = call.arguments; - // text/json/buffer(stream) → a pending promise the accumulate-and- - // settle machinery resolves at the terminal point (the utf8 decode / the parsed - // JSON dyn tree / the concatenated bytes) or rejects — with the stream's - // error, ERR_STREAM_PREMATURE_CLOSE on an early close, or json's - // SyntaxError on malformed text, Node's own rejection set. - // arrayBuffer/blob return null here: neither value has a - // representation, so the module-qualified fence (with the pointed - // hints in BUILTIN_MODULE_FENCE_HINTS) speaks. - if (bi.member === "text" || bi.member === "json" || bi.member === "buffer") { + // text/json/buffer/arrayBuffer(stream) → a pending promise the + // accumulate-and-settle machinery resolves at the terminal point (the + // utf8 decode / the parsed JSON dyn tree / the concatenated bytes / an + // ArrayBuffer dyn over them) or rejects — with the stream's error, + // ERR_STREAM_PREMATURE_CLOSE on an early close, or json's SyntaxError + // on malformed text, Node's own rejection set. blob returns null here: + // Blob has no representation, so the module-qualified fence (with the + // pointed hint in BUILTIN_MODULE_FENCE_HINTS) speaks. + if ( + bi.member === "text" || + bi.member === "json" || + bi.member === "buffer" || + bi.member === "arrayBuffer" + ) { if (args.length !== 1) { lowerer.noLowering( `stream/consumers.${bi.member} with ${args.length} arguments`, @@ -1668,7 +1673,7 @@ export function lowerStreamModuleCall( ); } const recv: IrExpr = argV; - const inner: IrType = bi.member === "text" ? STRING : bi.member === "json" ? DYN : BYTES; + const inner: IrType = bi.member === "text" ? STRING : bi.member === "buffer" ? BYTES : DYN; return { kind: "libCall", fn: `sc.${bi.member}` as IrLibFn, diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index c69c180f2..8ffb042c2 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1572,9 +1572,9 @@ export const BUILTIN_MODULE_FNS: Record< // exists so unlowered members fence module-qualified. "stream/promises": {}, // node:stream/consumers rides the stream spoke as well (text/json/ - // buffer special-case their stream argument); arrayBuffer and blob - // fence module-qualified with the pointed hints below — neither value - // has a representation in a compiled binary. + // buffer/arrayBuffer special-case their stream argument); blob fences + // module-qualified with the pointed hint below — Blob has no + // representation in a compiled binary. "stream/consumers": {}, // node:timers/promises — the delay-only setTimeout and bare setImmediate // (void promises the shared timer heap settles). The arity completion @@ -2384,9 +2384,6 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record< "the lowered shapes are const bindings over child_process.execFile and fs.readFile; call the bound function directly", }, "stream/consumers": { - arrayBuffer: - "collecting a stream directly into an ArrayBuffer has no native lowering yet; " + - "buffer(stream) collects the same bytes as a Buffer", blob: "Blob values have no representation in a compiled binary — " + "buffer(stream) collects the same bytes as a Buffer, text(stream) the decoded text", diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index a0b39c80a..2090dfd58 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -229,6 +229,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "sc.text", "sc.json", "sc.buffer", + "sc.arrayBuffer", "readable.newDyn", "writable.newDyn", "duplex.newDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a88af4920..da8c3b400 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2841,11 +2841,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * machinery: (s) → a pending promise settled at the terminal point * with the accumulated result (sc.text: the utf8 decode, sc.json: the * parsed dyn — malformed input rejects with the parse's SyntaxError, - * sc.buffer: the concatenated bytes) or rejected with the stream's + * sc.buffer: the concatenated bytes, sc.arrayBuffer: an ArrayBuffer + * dyn over them) or rejected with the stream's * error / ERR_STREAM_PREMATURE_CLOSE. */ "sc.text": { argTypes: [null], result: { kind: "promise", inner: STRING } }, "sc.json": { argTypes: [null], result: { kind: "promise", inner: DYN } }, "sc.buffer": { argTypes: [null], result: { kind: "promise", inner: BYTES_U8 } }, + "sc.arrayBuffer": { argTypes: [null], result: { kind: "promise", inner: DYN } }, "readable.newDyn": { argTypes: [DYN], result: VOID }, "writable.newDyn": { argTypes: [DYN], result: VOID }, "duplex.newDyn": { argTypes: [DYN], result: VOID }, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index c71c55d33..5827805e6 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1859,14 +1859,6 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, - { - "id": "node-builtin.stream.consumers.arrayBuffer", - "kind": "node-builtin", - "name": "stream/consumers.arrayBuffer", - "status": "unsupported", - "code": "SC2020", - "note": "collecting a stream directly into an ArrayBuffer has no native lowering yet; buffer(stream) collects the same bytes as a Buffer" - }, { "id": "node-builtin.stream.consumers.blob", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-stream-consumers-array-buffer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-stream-consumers-array-buffer.ts.json new file mode 100644 index 000000000..d3cfd0d3e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-stream-consumers-array-buffer.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4870-stream-consumers-array-buffer.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-buffer-inspect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-buffer-inspect.ts.json new file mode 100644 index 000000000..8d1d672ff --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-buffer-inspect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4871-array-buffer-inspect.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index bb0d77c2b..10d7d2a66 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -650,6 +650,60 @@ ScrStr *scr_insp_buffer(ScrBytes *b) { return ib_take(&out); } +/* ArrayBuffer/SharedArrayBuffer: formatArrayBuffer's + * `[Uint8Contents]: ` (the first maxArrayLength = 100 bytes, + * then ` ... N more byte(s)`) followed by the byteLength key, inside the + * class braces. `[ArrayBuffer]` beyond depth. */ +static ScrStr *scr_insp_array_buffer(const ScrBytes *h, const char *cls, double recurse, + double depth) { + if (recurse > depth) { + InspBuf out = {0}; + ib_char(&out, '['); + ib_cstr(&out, cls); + ib_char(&out, ']'); + return ib_take(&out); + } + size_t len = (size_t)scr_bytes_byte_len(h); + size_t shown = len < 100 ? len : 100; + uint8_t head[100]; + if (shown) scr_bytes_read(h, 0, head, shown); + static const char hex[] = "0123456789abcdef"; + InspBuf contents = {0}; + ib_cstr(&contents, "[Uint8Contents]: <"); + for (size_t i = 0; i < shown; i++) { + if (i) ib_char(&contents, ' '); + ib_char(&contents, hex[head[i] >> 4]); + ib_char(&contents, hex[head[i] & 15]); + } + if (len > shown) { + char more[64]; + size_t remaining = len - shown; + int n = snprintf(more, sizeof more, " ... %zu more byte%s", remaining, remaining > 1 ? "s" : ""); + ib_bytes(&contents, more, (size_t)n); + } + ib_char(&contents, '>'); + char byte_length[48]; + int bn = snprintf(byte_length, sizeof byte_length, "[byteLength]: %zu", len); + scr_insp_begin(recurse + 1); + ScrStr *entry = ib_take(&contents); + scr_insp_entry(entry, false); + scr_str_release(entry); + entry = scr_str_new(byte_length, (size_t)bn); + scr_insp_entry(entry, false); + scr_str_release(entry); + InspBuf open = {0}; + ib_cstr(&open, cls); + ib_cstr(&open, " {"); + ScrStr *base = scr_str_new("", 0); + ScrStr *b0 = ib_take(&open); + ScrStr *b1 = scr_str_new("}", 1); + ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, false, false); + scr_str_release(base); + scr_str_release(b0); + scr_str_release(b1); + return out; +} + /* Errors: the STACKLESS rendering — compiled binaries carry no stack, so * the base is formatError's bracket form `[Name: message]` / `[Name]` * (Node prints these exact forms for an error whose stack is empty; @@ -908,6 +962,10 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { return ib_take(&out); } case SCR_DYN_HANDLE: { + if (d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || + d->v.handle.tag == SCR_DYNH_SHARED_ARRAY_BUFFER) { + return scr_insp_array_buffer(d->v.handle.ptr, scr_dyn_handle_cls(d), recurse, depth); + } /* Node prints the class's full property dump (ServerResponse { * ... }) — internals we do not model. Fence loudly instead of a * silent-wrong shape (SEMANTICS.md). */ diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 7ba451a32..115b6dbf7 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2922,13 +2922,15 @@ ScrPromise *scr_sp_pipeline(double n, ScrStream **streams); * and settle at the terminal point (right after 'close', the eos timing * Node's consumers share) — text answers the utf8 decode, json parses * the text (malformed input rejects with the parse's SyntaxError; the - * result is a +1 dyn tree), buffer the concatenated bytes. Stream - * errors reject; an early close rejects ERR_STREAM_PREMATURE_CLOSE; a - * stream with no readable side rejects Node's async-iterable TypeError. + * result is a +1 dyn tree), buffer the concatenated bytes, arrayBuffer + * a +1 ArrayBuffer dyn over them. Stream errors reject; an early close + * rejects ERR_STREAM_PREMATURE_CLOSE; a stream with no readable side + * rejects Node's async-iterable TypeError. * Streams borrowed; +1 promises. */ ScrPromise *scr_sc_text(ScrStream *s); ScrPromise *scr_sc_json(ScrStream *s); ScrPromise *scr_sc_buffer(ScrStream *s); +ScrPromise *scr_sc_array_buffer(ScrStream *s); /* Readable.from(array): +1 fully-seeded object-entry stream (one WHOLE * chunk per element — strings or Buffers per the flag; hwm 1, already * EOF'd). Borrows arr. */ diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 72eae91de..0bcdd2851 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -2227,7 +2227,7 @@ ScrPromise *scr_sp_pipeline(double n, ScrStream **streams) { } /* ── node:stream/consumers ──────────────────────────────────────────── - * text/json/buffer over the readable machinery: a native 'data' listener + * text/json/buffer/arrayBuffer over the readable machinery: a native 'data' listener * accumulates every chunk (Buffer chunks as-is; string chunks — an * encoded stream or Readable.from strings — as their utf8 bytes, the * Blob rule Node's consumers share for whole-stream accumulation), and @@ -2239,7 +2239,7 @@ ScrPromise *scr_sp_pipeline(double n, ScrStream **streams) { * closures share the cap layout: caps[0] the pending promise, caps[1] * the chunk list (Buffer entries). */ -enum { SCR_SC_TEXT = 0, SCR_SC_JSON = 1, SCR_SC_BUFFER = 2 }; +enum { SCR_SC_TEXT = 0, SCR_SC_JSON = 1, SCR_SC_BUFFER = 2, SCR_SC_ARRAY_BUFFER = 3 }; static void scr_sc_settle_ok(ScrClosure *cb, int kind) { ScrPromise *p = scr_box_get_ref(cb->caps[0]); /* +1 */ @@ -2251,6 +2251,15 @@ static void scr_sc_settle_ok(ScrClosure *cb, int kind) { scr_promise_release(p); return; } + if (kind == SCR_SC_ARRAY_BUFFER) { + /* The concatenation is a fresh exact-length owner, so the buffer + * wraps it with no second copy (Node copies into a Blob, then out). */ + ScrDyn *ab = scr_array_buffer_from_bytes(all); + scr_bytes_release(all); + scr_promise_fulfill_ref(p, ab, scr_dyn_retain_v, scr_dyn_release_v, NULL); + scr_promise_release(p); + return; + } ScrStr *enc = scr_str_new("utf8", 4); ScrStr *text = scr_bytes_to_str(all, enc); /* U+FFFD per invalid subpart */ scr_str_release(enc); @@ -2309,6 +2318,10 @@ static void scr_sc_fin_buffer(ScrClosure *cb, ScrStream *s, ScrError *err) { (void)s; scr_sc_fin(cb, err, SCR_SC_BUFFER); } +static void scr_sc_fin_array_buffer(ScrClosure *cb, ScrStream *s, ScrError *err) { + (void)s; + scr_sc_fin(cb, err, SCR_SC_ARRAY_BUFFER); +} static ScrClosure *scr_sc_closure(void *fn, ScrPromise *p, ScrArr *chunks) { ScrClosure *c = scr_closure_new(fn, 2); @@ -2333,7 +2346,8 @@ static ScrPromise *scr_sc_consume(ScrStream *s, int kind) { ScrArr *chunks = scr_arr_new_ops(&bytes_ops, 4); ScrStreamErrInv fin_inv = kind == SCR_SC_TEXT ? &scr_sc_fin_text : kind == SCR_SC_JSON ? &scr_sc_fin_json - : &scr_sc_fin_buffer; + : kind == SCR_SC_BUFFER ? &scr_sc_fin_buffer + : &scr_sc_fin_array_buffer; /* The terminal watcher first (it marks lifecycle errors handled); the * promise form exposes no unhook — the cleanup closure drops. */ ScrClosure *cleanup = scr_stream_finished(s, scr_sc_closure((void *)fin_inv, p, chunks), fin_inv); @@ -2353,6 +2367,7 @@ static ScrPromise *scr_sc_consume(ScrStream *s, int kind) { ScrPromise *scr_sc_text(ScrStream *s) { return scr_sc_consume(s, SCR_SC_TEXT); } ScrPromise *scr_sc_json(ScrStream *s) { return scr_sc_consume(s, SCR_SC_JSON); } ScrPromise *scr_sc_buffer(ScrStream *s) { return scr_sc_consume(s, SCR_SC_BUFFER); } +ScrPromise *scr_sc_array_buffer(ScrStream *s) { return scr_sc_consume(s, SCR_SC_ARRAY_BUFFER); } /* ── the readable surface ─────────────────────────────────────────────── */ diff --git a/tests/corpus/4870-stream-consumers-array-buffer.ts b/tests/corpus/4870-stream-consumers-array-buffer.ts new file mode 100644 index 000000000..50dfd1351 --- /dev/null +++ b/tests/corpus/4870-stream-consumers-array-buffer.ts @@ -0,0 +1,81 @@ +// stream/consumers.arrayBuffer — the accumulated bytes as a fresh +// ArrayBuffer: pooled small Buffer chunks and a lone chunk never alias +// the result, string chunks contribute their utf8 bytes, an empty stream +// answers a zero-length buffer, and the result works with every view. +// Settlement lands after 'close' and before a later macrotask, and the +// rejection set matches the other consumers: the stream's error and +// ERR_STREAM_PREMATURE_CLOSE. +import { Readable } from "node:stream"; +import { arrayBuffer } from "node:stream/consumers"; + +function source(...chunks: (Buffer | string)[]): Readable { + const r = new Readable({ read() {} }); + for (const c of chunks) r.push(c); + r.push(null); + return r; +} + +async function main(): Promise { + const ab = await arrayBuffer(source(Buffer.from("hi"), Buffer.from([0xff, 0]))); + console.log(ab); + console.log(ab.byteLength, ab instanceof ArrayBuffer); + const u8 = new Uint8Array(ab); + console.log(Array.from(u8), u8.length); + const dv = new DataView(ab); + console.log(dv.getUint16(0), dv.getUint8(2)); + console.log(Array.from(new Uint8Array(ab.slice(1, 3)))); + console.log(Buffer.from(ab).toString("hex")); + + // A single chunk still copies: writes through the result's view do not + // reach the source chunk. + const chunk = Buffer.from([1, 2, 3, 4]); + const lone = await arrayBuffer(source(chunk)); + new Uint8Array(lone)[0] = 9; + console.log(chunk[0], new Uint8Array(lone)[0], lone.byteLength); + + // String chunks (setEncoding) contribute their utf8 bytes. + const encoded = new Readable({ read() {} }); + encoded.setEncoding("utf8"); + encoded.push("é"); + encoded.push("x"); + encoded.push(null); + console.log(Array.from(new Uint8Array(await arrayBuffer(encoded)))); + + const empty = await arrayBuffer(source()); + console.log(empty, empty.byteLength); + + // Timing: after 'close', before a setImmediate queued at end. + const timed = new Readable({ read() {} }); + const order: string[] = []; + timed.on("close", () => order.push("close")); + const settled = arrayBuffer(timed).then((b) => order.push(`arrayBuffer(${b.byteLength})`)); + timed.push("abc"); + timed.push(null); + setImmediate(() => order.push("immediate")); + await settled; + await new Promise((resolve) => setImmediate(resolve)); + console.log(order.join(",")); + + const failing = new Readable({ read() {} }); + const p1 = arrayBuffer(failing); + failing.push("x"); + failing.destroy(new Error("mid-stream failure")); + try { + await p1; + console.log("SHOULD NOT PRINT"); + } catch (e) { + if (e instanceof Error) console.log("error:", e.message); + } + + const closed = new Readable({ read() {} }); + const p2 = arrayBuffer(closed); + closed.destroy(); + try { + await p2; + console.log("SHOULD NOT PRINT"); + } catch (e) { + console.log("premature:", (e as NodeJS.ErrnoException).code); + } +} + +void main(); diff --git a/tests/corpus/4871-array-buffer-inspect.ts b/tests/corpus/4871-array-buffer-inspect.ts new file mode 100644 index 000000000..090290271 --- /dev/null +++ b/tests/corpus/4871-array-buffer-inspect.ts @@ -0,0 +1,27 @@ +// ArrayBuffer and SharedArrayBuffer inspection: Node's formatArrayBuffer +// `[Uint8Contents]: ` entry (the first 100 bytes, then +// `... N more byte(s)`) plus the byteLength entry inside the class +// braces, breaking onto lines past the 80-column budget, nested in +// objects and arrays, `[ArrayBuffer]` beyond the depth limit, and the +// backing buffer of a non-byte typed array. +import { inspect } from "node:util"; + +for (const n of [0, 1, 3, 16, 17, 18, 19, 20, 24, 100, 101, 102, 300]) { + const ab = new ArrayBuffer(n); + const view = new Uint8Array(ab); + for (let i = 0; i < n; i++) view[i] = (i * 37) & 255; + console.log(n, ab); +} + +const small = new ArrayBuffer(2); +console.log({ a: small, b: [small] }); +console.log({ deep: { deeper: { deepest: { ab: small } } } }); +console.log([new ArrayBuffer(40), 1]); +console.log(inspect(small)); +console.log(`${inspect({ x: new ArrayBuffer(4) })}`); + +console.log(new SharedArrayBuffer(3)); + +const f64 = new Float64Array(2); +f64[0] = 1.5; +console.log(f64.buffer);