From 5d7234a61c9067aeb9180f99594ecc4ceb9fff7a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:12:27 -0500 Subject: [PATCH 1/3] Concatenate untyped Buffer lists as Node does Buffer.concat lowered only a statically typed Buffer[]/Uint8Array[] list or an array literal. Any other list, such as JavaScript's `const chunks = []`, reached an SC2020 fence at run time. Such lists now go to the runtime, which checks them as Node does (an Array whose elements are Uint8Arrays, ERR_INVALID_ARG_TYPE with Node's messages otherwise) before concatenating; the length form is covered as well. --- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/containers/bytes.ts | 22 +++++++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 2 + .../corpus/5973-buffer-concat-untyped.js.json | 6 +++ packages/runtime/src/scr_bytes.h | 4 ++ packages/runtime/src/scr_bytes_io.c | 46 +++++++++++++++++++ tests/corpus/5973-buffer-concat-untyped.js | 20 ++++++++ tests/harness/corpus-costs.json | 2 + 9 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json create mode 100644 tests/corpus/5973-buffer-concat-untyped.js diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 6516756e9..343393c2a 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -404,6 +404,8 @@ export const LIB_FN_SYMS: Record = { "buffer.fromDyn": "scr_buffer_from_dyn", "buffer.concat": "scr_buffer_concat", "buffer.concatLen": "scr_buffer_concat_len", + "buffer.concatDyn": "scr_buffer_concat_dyn", + "buffer.concatLenDyn": "scr_buffer_concat_len_dyn", "buffer.alloc": "scr_buffer_alloc", "buffer.allocUnsafe": "scr_buffer_alloc_unsafe", "buffer.allocUnsafeSlow": "scr_buffer_alloc_unsafe_slow", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 7a19927de..c41bf5163 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1511,11 +1511,23 @@ function lowerBufferStaticValue( } else { list = lowerer.lowerExpr(argNode); if (!typeEquals(list.type, arrayOf(BYTES_U8))) { - lowerer.noLowering( - `Buffer.concat of '${lowerer.fmt(list.type)}' values`, - argNode, - "one Uint8Array[]/Buffer[] value (or literal) is the lowered list shape", - ); + // Any other list (JavaScript's untyped `const chunks = []`, or a + // union of arrays): the runtime checks it as Node does (an Array of + // Uint8Arrays, the ERR_INVALID_ARG_TYPE errors otherwise) before + // concatenating. + const value: IrExpr = + list.type.kind === "dyn" ? list : { kind: "dynFrom", value: list, type: DYN, loc }; + if (args.length === 2) { + const total = lowerer.lowerExprExpecting(args[1]!, F64); + return { + kind: "libCall", + fn: "buffer.concatLenDyn", + args: [value, total], + type: BYTES_U8, + loc, + }; + } + return { kind: "libCall", fn: "buffer.concatDyn", args: [value], type: BYTES_U8, loc }; } } if (args.length === 2) { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 96bd381f7..a872f3f42 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -670,6 +670,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "crypto.scryptCb", "buffer.concat", "buffer.concatLen", + "buffer.concatDyn", + "buffer.concatLenDyn", "buffer.fromDyn", "buffer.fromStrChecked", "buffer.alloc", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 6c09eeb01..9eb0948e0 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2575,6 +2575,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * opaque reference inputs retain explicit runtime refusals. */ "buffer.fromDyn": { argTypes: [DYN, STRING], result: BYTES_U8 }, "buffer.concat": { argTypes: [arrayOf(BYTES_U8)], result: BYTES_U8 }, + "buffer.concatDyn": { argTypes: [DYN], result: BYTES_U8 }, /** The pooled Buffer factories (Node's allocate/fromStringFast/ * fromArrayLike): allocUnsafe, Buffer.from of strings, Uint8Arrays, * number[] and checked values, and concat return views into the @@ -2601,6 +2602,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * zero-padded to the total. THROWS Node's 'length' RangeError on a * negative/non-integer total (may-throw seed). */ "buffer.concatLen": { argTypes: [arrayOf(BYTES_U8), F64], result: BYTES_U8 }, + "buffer.concatLenDyn": { argTypes: [DYN, F64], result: BYTES_U8 }, // The checked-dynamic compare/equals validators (Node's argument // ladders over dyn-boxed invalid-input probes). /** The checked-dynamic compare/equals validators (scr_bytes_io.c) — diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json new file mode 100644 index 000000000..3fffd75c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5973-buffer-concat-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5973-buffer-concat-untyped.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4309154c9..993921043 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -389,6 +389,10 @@ ScrBytes *scr_buffer_from_arr(const ScrArr *arr); /* number[] */ ScrBytes *scr_buffer_from_dyn_pooled(const ScrDyn *value, const ScrStr *encoding); ScrBytes *scr_buffer_concat(const ScrArr *list); ScrBytes *scr_buffer_concat_len(const ScrArr *list, double total); +/* The untyped-list forms: Node's ERR_INVALID_ARG_TYPE checks on the list + * and each element, then the typed concat. NULL with the exception pending. */ +ScrBytes *scr_buffer_concat_dyn(const struct ScrDyn *list); +ScrBytes *scr_buffer_concat_len_dyn(const struct ScrDyn *list, double total); /* Drops the pool's slab reference and restores Buffer.poolSize (process * exit audits and library session resets). */ void scr_buffer_pool_reset(void); diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 6fe829eeb..fc7b3bbb8 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1249,6 +1249,52 @@ static ScrBytes *scr_buffer_from_array_like(const ScrDyn *value, double length) return out; } +/* Buffer.concat over an untyped list: Node's checks (an Array whose every + * element is a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the typed + * concat over the elements. total < 0 selects the one-argument form. NULL + * with the exception pending. */ +static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bool has_total) { + ScrDyn *materialized = NULL; + if (list->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(list) != NULL) { + materialized = scr_dyn_typed_ref_materialize(list); + list = materialized; + } + if (list->kind != SCR_DYN_ARR) { + scr_dyn_arg_type_fail("list", "an instance of Array", list); + scr_dyn_release(materialized); + return NULL; + } + size_t n = list->v.arr.len; + ScrArr *parts = scr_arr_new(SCR_ELEM_BYTES, (double)n); + for (size_t i = 0; i < n; i++) { + ScrDyn *item = scr_dyn_index_get(list, (double)i); + const ScrDyn *shown = item != NULL ? item : scr_dyn_undefined(); + if (!scr_dyn_bytes_is(shown, SCR_BYTES_U8) || shown->v.bytes->is_data_view) { + char name[40]; + snprintf(name, sizeof name, "list[%zu]", i); + scr_dyn_arg_type_fail(name, "an instance of Buffer or Uint8Array", shown); + scr_dyn_release(item); + scr_arr_release(parts); + scr_dyn_release(materialized); + return NULL; + } + scr_arr_push_ref(parts, scr_bytes_retain(shown->v.bytes)); + scr_dyn_release(item); + } + ScrBytes *out = has_total ? scr_buffer_concat_len(parts, total) : scr_buffer_concat(parts); + scr_arr_release(parts); + scr_dyn_release(materialized); + return out; +} + +ScrBytes *scr_buffer_concat_dyn(const ScrDyn *list) { + return scr_buffer_concat_dyn_impl(list, 0, false); +} + +ScrBytes *scr_buffer_concat_len_dyn(const ScrDyn *list, double total) { + return scr_buffer_concat_dyn_impl(list, total, true); +} + ScrBytes *scr_buffer_from_dyn(const ScrDyn *value, const ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_from_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_convert(SCR_BYTES_U8, value->v.bytes); diff --git a/tests/corpus/5973-buffer-concat-untyped.js b/tests/corpus/5973-buffer-concat-untyped.js new file mode 100644 index 000000000..54256c935 --- /dev/null +++ b/tests/corpus/5973-buffer-concat-untyped.js @@ -0,0 +1,20 @@ +// Buffer.concat over an untyped list (JavaScript's `const chunks = []`): +// Uint8Array elements concatenate, the length form pads or truncates, and +// a non-array list or a non-Uint8Array element throws Node's TypeError. +const chunks = []; +chunks.push(Buffer.from("ab")); +chunks.push(new Uint8Array([99, 100])); +console.log(Buffer.concat(chunks).toString()); +console.log(Buffer.concat(chunks, 6)); +console.log(Buffer.concat(chunks, 3).toString()); +console.log(Buffer.concat([]).length); + +const lists = [[1], ["a"], [Buffer.from("x"), null], "x", 5, [new Uint16Array(1)]]; +for (const list of lists) { + try { + Buffer.concat(list); + console.log("no error"); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 24eca7d43..9551e9ad0 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1901,6 +1901,7 @@ "5970-net-connect-host-name.ts": 2400, "5971-buffer-chunks-to-string.ts": 2700, "5972-net-set-encoding-listeners.ts": 2550, + "5973-buffer-concat-untyped.js": 1900, "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, @@ -4588,6 +4589,7 @@ "5970-net-connect-host-name.ts": 2800, "5971-buffer-chunks-to-string.ts": 3100, "5972-net-set-encoding-listeners.ts": 3000, + "5973-buffer-concat-untyped.js": 2900, "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, From 57b6a4460c37590a541b6821534369c3c6f7d8d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 08:03:20 -0500 Subject: [PATCH 2/3] Compare the zlib-shims npm case with Node With untyped Buffer.concat lists and typeof on builtin module exports supported, the fixture runs to completion and matches Node, so its expected deferred SC2020 refusal is removed. --- tests/harness/npm.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index a6cdee2b5..71b8a61d1 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -109,7 +109,6 @@ const KNOWN: Record = { "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" }, "workspace-unclean": { build: "SC2013" }, - "zlib-shims": { run: "SC2020" }, }; const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); From 05b63df116c9ee10091f75656b96b56b630f3e1f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 18:05:09 -0500 Subject: [PATCH 3/3] Check Buffer.concat's length before the elements of an untyped list Node's Buffer.concat(list, length) validates length (after returning early for an empty list) before it checks that every element is a Uint8Array. The untyped-list path checked the elements first, so a bad element and a bad length threw ERR_INVALID_ARG_TYPE where Node throws the length's ERR_OUT_OF_RANGE. --- packages/runtime/src/scr_bytes_io.c | 12 ++++++++---- tests/corpus/5973-buffer-concat-untyped.js | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index fc7b3bbb8..4d7ef5f67 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1249,10 +1249,10 @@ static ScrBytes *scr_buffer_from_array_like(const ScrDyn *value, double length) return out; } -/* Buffer.concat over an untyped list: Node's checks (an Array whose every - * element is a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the typed - * concat over the elements. total < 0 selects the one-argument form. NULL - * with the exception pending. */ +/* Buffer.concat over an untyped list: Node's checks in Node's order (an + * Array, then the total of the two-argument form unless the list is empty, + * then every element a Uint8Array, ERR_INVALID_ARG_TYPE otherwise), then the + * typed concat over the elements. NULL with the exception pending. */ static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bool has_total) { ScrDyn *materialized = NULL; if (list->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(list) != NULL) { @@ -1265,6 +1265,10 @@ static ScrBytes *scr_buffer_concat_dyn_impl(const ScrDyn *list, double total, bo return NULL; } size_t n = list->v.arr.len; + if (has_total && n > 0 && !scr_bytes_validate_off("length", total, 9007199254740991.0)) { + scr_dyn_release(materialized); + return NULL; + } ScrArr *parts = scr_arr_new(SCR_ELEM_BYTES, (double)n); for (size_t i = 0; i < n; i++) { ScrDyn *item = scr_dyn_index_get(list, (double)i); diff --git a/tests/corpus/5973-buffer-concat-untyped.js b/tests/corpus/5973-buffer-concat-untyped.js index 54256c935..116ca0a2b 100644 --- a/tests/corpus/5973-buffer-concat-untyped.js +++ b/tests/corpus/5973-buffer-concat-untyped.js @@ -1,6 +1,8 @@ // Buffer.concat over an untyped list (JavaScript's `const chunks = []`): // Uint8Array elements concatenate, the length form pads or truncates, and -// a non-array list or a non-Uint8Array element throws Node's TypeError. +// a non-array list or a non-Uint8Array element throws Node's TypeError. The +// length form checks the length before the elements, and an empty list +// returns before either. const chunks = []; chunks.push(Buffer.from("ab")); chunks.push(new Uint8Array([99, 100])); @@ -18,3 +20,19 @@ for (const list of lists) { console.log(e.name, e.code, e.message); } } + +const withLength = [ + [[1], -1], + [[Buffer.from("a"), "b"], NaN], + [[1], 2], + [[], -1], + [[Buffer.from("a")], 1.5], + [[Buffer.from("a"), Buffer.from("b")], 2], +]; +for (const [list, length] of withLength) { + try { + console.log("length", Buffer.concat(list, length).length); + } catch (e) { + console.log(e.name, e.code, e.message); + } +}