From fb3806ce42a7717c84e4ce4e285bf62caf86d35a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 03:54:26 -0500 Subject: [PATCH 01/11] Search TypedArrays, read stdin through 'readable', and assign constructor-function globals - TypedArray indexOf, lastIndexOf and includes on every element kind, typed and checked-dynamic: strict equality (SameValueZero for includes) and fromIndex as ToIntegerOrInfinity. A Buffer reached through a Uint8Array value keeps Buffer's search. - process.stdin 'readable' with read() and read(size), including sizes above the read-ahead mark and input a once('readable') listener leaves for a later 'data' listener. for await over process.stdin yields Buffers. - Module bindings holding `new F()` of a constructor function can be read and reassigned from other functions. --- .../compiler/src/backend/llvm/expr-bytes.ts | 16 +++ .../src/frontend/lowering/containers/bytes.ts | 49 +++++++- .../src/frontend/lowering/lower-modules.ts | 1 - .../frontend/lowering/object-factory-new.ts | 31 +++-- packages/compiler/src/ir/ir.ts | 7 ++ packages/compiler/src/ir/validate.ts | 4 + .../corpus/7680-typed-array-search.ts.json | 6 + .../7681-typed-array-search-dynamic.mjs.json | 6 + .../7682-constructor-function-globals.js.json | 6 + packages/runtime/src/scr_bytes.c | 44 +++++++ packages/runtime/src/scr_bytes.h | 2 + packages/runtime/src/scr_dyn_invoke.c | 15 +++ packages/runtime/src/scr_events.c | 115 ++++++++++++++++-- tests/corpus/7680-typed-array-search.ts | 63 ++++++++++ .../7681-typed-array-search-dynamic.mjs | 28 +++++ .../7682-constructor-function-globals.js | 55 +++++++++ .../event-loop/stdin-for-await-buffers.mjs | 10 ++ .../event-loop/stdio-readable-large-size.mjs | 9 ++ .../event-loop/stdio-readable-once-rest.mjs | 12 ++ .../event-loop/stdio-readable-size.mjs | 18 +++ tests/fixtures/event-loop/stdio-readable.mjs | 19 +++ .../7680-typed-array-search.ts.json | 1 + .../7681-typed-array-search-dynamic.mjs.json | 1 + .../7682-constructor-function-globals.js.json | 1 + tests/harness/event-loop-cases.ts | 49 ++++++++ 25 files changed, 543 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json create mode 100644 tests/corpus/7680-typed-array-search.ts create mode 100644 tests/corpus/7681-typed-array-search-dynamic.mjs create mode 100644 tests/corpus/7682-constructor-function-globals.js create mode 100644 tests/fixtures/event-loop/stdin-for-await-buffers.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-large-size.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-once-rest.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-size.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable.mjs create mode 100644 tests/harness/corpus-costs/7680-typed-array-search.ts.json create mode 100644 tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json create mode 100644 tests/harness/corpus-costs/7682-constructor-function-globals.js.json diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 41ddf2b9d..2a368f0e2 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -936,6 +936,22 @@ export function emitBytesIntrinsic( B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`); return { name: t, type: e.type }; } + case "indexOfElem": + case "lastIndexOfElem": + case "includesElem": { + const includes = method === "includesElem"; + const idx = call( + "scr_bytes_ta_index_of", + "double (ptr, double, double, i1 zeroext, i1 zeroext, i1 zeroext)", + `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]?.name ?? f64Lit(0)}, i1 ${args.length > 1}, i1 ${method !== "lastIndexOfElem"}, i1 ${includes}`, + false, + false, + ); + if (!includes) return idx; + const t = B.tmp(); + B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`); + return { name: t, type: e.type }; + } case "fillElem": // Per-element TypedArray fill (non-u8): slice-style index // defaults, never throws; the receiver comes back +1. diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 995cb177a..9cf68da9f 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -907,9 +907,10 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { * (shared view), toReversed, copyWithin, set(src, * offset?), and the u8-only Buffer surface: toString(enc?) plus the * whole numeric read/write family (fixed widths BE/LE and the - * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS). Everything - * else the lib declares (fill, indexOf, reverse, ...) falls through to - * the SC2020 member fence. Null when this isn't a bytes method call. */ + * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS), and + * TypedArray fill, indexOf, lastIndexOf and includes. Everything else the + * lib declares (reverse, sort, ...) falls through to the SC2020 member + * fence. Null when this isn't a bytes method call. */ export function lowerBytesMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1101,6 +1102,43 @@ export function lowerBytesMethodCall( loc, }; } + if ( + (name === "indexOf" || name === "lastIndexOf" || name === "includes") && + !(() => { + const symbol = lowerer.checker.getSymbolAtLocation(access.name); + return ( + symbol !== undefined && + symbol.declarations.some( + (declaration) => + ts.isInterfaceDeclaration(declaration.parent) && + declaration.parent.name.text === "Buffer", + ) + ); + })() + ) { + // TypedArray search: strict equality (includes: SameValueZero) with a + // relative fromIndex. Buffer's same-named methods keep their byte, + // string and Buffer needles. + if (nArgs < 1 || nArgs > 2 || call.arguments.nodes.some(ts.isSpreadElement)) { + lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call); + } + const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); + const args = call.arguments.nodes.map((a) => lowerer.lowerExprExpecting(a, F64)); + const method = + name === "indexOf" + ? "indexOfElem" + : name === "lastIndexOf" + ? "lastIndexOfElem" + : "includesElem"; + return { + kind: "bytesIntrinsic", + method, + receiver, + args, + type: name === "includes" ? BOOL : F64, + loc, + }; + } if (name === "set") { if (nArgs < 1 || nArgs > 2) { lowerer.noLowering(`.set with ${nArgs} arguments on typed arrays`, call); @@ -1210,9 +1248,8 @@ export function lowerBytesMethodCall( // The Buffer-declared comparison/search/mutation surface. All of // these resolve against the Buffer interface (the checker keeps most // off plain typed arrays; where the lib DOES declare a same-named - // TypedArray member — indexOf, includes, fill — the semantics differ, - // so only Buffer-declared resolutions lower and u8 receivers gate the - // rest). + // TypedArray member — indexOf, includes, fill — the semantics differ: + // those resolutions lowered above, and u8 receivers gate the rest). const declOnBuffer = (() => { const nameSym = lowerer.checker.getSymbolAtLocation(access.name); return ( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5238333ad..af2ed4a2c 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2361,7 +2361,6 @@ export function collectGlobals( // References from separately-declared functions cascade to // their own per-site runtime fences. const factoryType = - !isLet && isJsSourceFile(sf) && !decl.type && !hasJsTypeAnnotation(decl) && diff --git a/packages/compiler/src/frontend/lowering/object-factory-new.ts b/packages/compiler/src/frontend/lowering/object-factory-new.ts index bcb675692..c672f82e2 100644 --- a/packages/compiler/src/frontend/lowering/object-factory-new.ts +++ b/packages/compiler/src/frontend/lowering/object-factory-new.ts @@ -6,10 +6,14 @@ import { bindingNeverReassigned } from "./binding-analysis.js"; import { implicitDefaultInstance } from "./generic-functions.js"; import { type FnSig } from "./call-signatures.js"; -/** An ordinary constructor's explicit object return replaces its receiver. - * Without observing this or new.target, it can use the function's call ABI. */ -function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { - if (!ts.isIdentifier(expr.expression) || lowerer.peekLocal(expr.expression)) return false; +/** The JavaScript function declaration (or never-reassigned function + * expression binding) `new` calls, when it is an ordinary constructor: not + * a generator or async function. */ +function jsConstructorFunctionOf( + lowerer: Lowerer, + expr: ts.NewExpression, +): ts.FunctionDeclaration | ts.FunctionExpression | null { + if (!ts.isIdentifier(expr.expression) || lowerer.peekLocal(expr.expression)) return null; // This probe also sees ordinary classes during global collection. Leave // their deferred diagnostics for the class declaration's lowering. const wasCollecting = lowerer.collecting; @@ -20,7 +24,7 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { } finally { lowerer.collecting = wasCollecting; } - if (!symbol) return false; + if (!symbol) return null; const owner = symbol.declarations.find( (node) => (ts.isFunctionDeclaration(node) && node.body !== undefined) || @@ -45,7 +49,15 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { declaration.asteriskToken || declaration.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ) - return false; + return null; + return declaration; +} + +/** An ordinary constructor's explicit object return replaces its receiver. + * Without observing this or new.target, it can use the function's call ABI. */ +function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { + const declaration = jsConstructorFunctionOf(lowerer, expr); + if (!declaration?.body) return false; const objectReturn = (expression: ts.Expression): boolean => ts.isObjectLiteralExpression(expression) || ts.isArrayLiteralExpression(expression) || @@ -81,8 +93,13 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { /** Collection must register the shared result before reaching factory * bodies: those bodies can reference later globals in the same module. */ +/** The storage of a JavaScript module binding initialized with `new` of an + * ordinary function: a factory's result and a constructor function's + * receiver (`const head = new Segment()`) are both checked-dynamic + * values, which functions declared elsewhere in the module read through + * the global. */ export function objectFactoryGlobalType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { - return isObjectFactoryNew(lowerer, expr) ? DYN : null; + return jsConstructorFunctionOf(lowerer, expr) !== null ? DYN : null; } export function objectFactorySignature(lowerer: Lowerer, expr: ts.NewExpression): FnSig | null { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 6d0760b81..a26cb638f 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2484,6 +2484,13 @@ export type IrBytesIntrinsicMethod = * (same observable number-fill result; Buffer's throwing offset * validation). */ | "fillElem" + /** TypedArray.prototype.indexOf / lastIndexOf / includes on any + * receiver: [f64 searchElement, f64 fromIndex?] → f64 index (-1 when + * absent) or bool; strict equality (includes: SameValueZero), a present + * fromIndex counts from the end when negative, never throws. */ + | "indexOfElem" + | "lastIndexOfElem" + | "includesElem" | "copy" | "swap16" | "swap32" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 02825f0de..77909fb52 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2196,6 +2196,10 @@ function validateFunction( fillNum: { argTypes: [F64, F64, F64], minArgs: 1, result: BYTES_U8 }, // Per-element TypedArray fill (any elem — the non-u8 fill path). fillElem: { argTypes: [F64, F64, F64], minArgs: 1, result: bytesOf(recv.elem) }, + // TypedArray search (any elem). + indexOfElem: { argTypes: [F64, F64], minArgs: 1, result: F64 }, + lastIndexOfElem: { argTypes: [F64, F64], minArgs: 1, result: F64 }, + includesElem: { argTypes: [F64, F64], minArgs: 1, result: BOOL }, fillStr: { argTypes: [STRING, STRING, F64, F64], minArgs: 2, result: BYTES_U8 }, copy: { argTypes: [BYTES_U8, F64, F64, F64], minArgs: 1, result: F64 }, swap16: { argTypes: [], minArgs: 0, result: BYTES_U8 }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json new file mode 100644 index 000000000..d93d9afdc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7680-typed-array-search.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json new file mode 100644 index 000000000..ab91940c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7681-typed-array-search-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json new file mode 100644 index 000000000..26d05338c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7682-constructor-function-globals.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7bcfd0cfb..26f1a57a5 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -3300,6 +3300,50 @@ double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd) return scr_bytes_index_of(b, &needle, off, 1, fwd); } +/* TypedArray.prototype.indexOf and lastIndexOf (strict equality, so NaN + * never matches) and includes (`same_value_zero`: NaN matches NaN). `from` + * is the fromIndex argument when `has_from` (ToIntegerOrInfinity, negative + * counts from the end), else the default: 0, or len - 1 searching + * backwards. Returns the element index, or -1. Never throws. */ +double scr_bytes_ta_index_of(const ScrBytes *b, double v, double from, bool has_from, bool forward, + bool same_value_zero) { + // A Buffer viewed as a Uint8Array keeps Buffer's own search methods, whose + // byte needle and byteOffset rules differ (an absent offset is NaN there). + if (scr_bytes_is_buffer(b)) return scr_bytes_index_of_num(b, v, has_from ? from : NAN, forward); + size_t len = b->len; + if (len == 0) return -1; + double k = has_from ? (from != from ? 0 : trunc(from)) : forward ? 0 : (double)len - 1; + bool nan = v != v; + if (nan && !same_value_zero) return -1; + bool bytes = (b->elem == SCR_BYTES_U8 || b->elem == SCR_BYTES_U8C) && !b->shared; + if (forward) { + if (k >= (double)len) return -1; + if (k < 0) k = k + (double)len < 0 ? 0 : k + (double)len; + size_t start = (size_t)k; + if (bytes) { + if (!(v >= 0 && v <= 255 && v == trunc(v))) return -1; + const uint8_t *hit = memchr(b->data + start, (int)v, len - start); + return hit ? (double)(hit - b->data) : -1; + } + for (size_t i = start; i < len; i++) { + double e = scr_bytes_load(b, i); + if (e == v || (nan && e != e)) return (double)i; + } + return -1; + } + if (k < 0) { + k += (double)len; + if (k < 0) return -1; + } else if (k > (double)len - 1) { + k = (double)len - 1; + } + for (size_t i = (size_t)k + 1; i-- > 0;) { + double e = scr_bytes_load(b, i); + if (e == v || (nan && e != e)) return (double)i; + } + return -1; +} + /* fill's shared core over a normalized byte pattern. nargs counts the * present offset/end args. An EMPTY pattern zero-fills when zero_ok * (Node's '' string) and is the constant TypeError otherwise (an empty diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 9b382dda5..13b31b183 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -499,6 +499,8 @@ ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n); void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name); double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, double align, bool fwd); double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd); +double scr_bytes_ta_index_of(const ScrBytes *b, double v, double from, bool has_from, bool forward, + bool same_value_zero); ScrBytes *scr_bytes_fill(ScrBytes *b, const ScrBytes *pattern, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_num(ScrBytes *b, double v, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc, diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 4039fda94..d57ca24ac 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -1704,6 +1704,21 @@ static ScrDyn *scr_dyn_invoke_impl( scr_str_release(joined); return out; } + if (!recv->buffer && + ((mid == DYN_M_indexOf) || (mid == DYN_M_lastIndexOf) || (mid == DYN_M_includes))) { + /* TypedArray search: strict equality (includes: SameValueZero), so a + * needle that is not a number matches nothing; fromIndex counts as + * given whenever it is passed, undefined included. */ + double from = 0; + if (argc > 1) { + if (!scr_dyn_number_coerce_js(args[1], &from)) return NULL; + } + bool includes = mid == DYN_M_includes; + double found = argc > 0 && args[0]->kind == SCR_DYN_NUM + ? scr_bytes_ta_index_of(bytes, args[0]->v.num, from, argc > 1, mid != DYN_M_lastIndexOf, includes) + : -1; + return includes ? scr_dyn_new_bool(found >= 0) : scr_dyn_new_num(found); + } if (dyn_bytes_proto_real(mid)) { dyn_throw_unsupported(scr_bytes_elem_name(bytes->elem), method); return NULL; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 3391f54b1..bc554a445 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -390,6 +390,13 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; +/* 'readable' listeners (paused mode, as Node's once one is added): input + * stays buffered for read(), and the event fires on the next tick when + * input arrives while a read wanted more (`need`), and once at EOF. */ +static SCR_TL ScrStdinEndL *scr_stdin_readable = NULL; +static SCR_TL size_t scr_stdin_nreadable = 0, scr_stdin_readable_cap = 0; +static SCR_TL bool scr_stdin_readable_need = false; +static SCR_TL bool scr_stdin_readable_queued = false; #ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until @@ -475,6 +482,78 @@ void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { scr_stdin_nend++; } +static void scr_stdin_finish(bool fire_end); + +static void scr_stdin_emit_readable(void) { + scr_stdin_readable_queued = false; + size_t length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + if (scr_stdin_eof || scr_stdin_destroyed || (!length && !scr_stdin_read_eof)) return; + size_t n = scr_stdin_nreadable; + ScrStdinEndL *snap = malloc((n ? n : 1) * sizeof *snap); + if (!snap) scr_events_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = scr_stdin_readable[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (snap[i].once) { + for (size_t j = 0; j < scr_stdin_nreadable; j++) { + if (scr_stdin_readable[j].cb == snap[i].cb) { + scr_closure_release(scr_stdin_readable[j].cb); + memmove(scr_stdin_readable + j, scr_stdin_readable + j + 1, + (scr_stdin_nreadable - j - 1) * sizeof *scr_stdin_readable); + scr_stdin_nreadable--; + break; + } + } + } + if (!scr_exc_pending() && !scr_stdin_eof && !scr_stdin_destroyed) + ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); + length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + /* Another event is due once the buffer reaches the 64 KiB mark, or the + * larger size a listener's read(size) asked for. */ + scr_stdin_readable_need = + !scr_stdin_read_eof && (length <= 65536 || length < scr_stdin_read_demand); +} + +static void scr_stdin_queue_readable(void) { + if (scr_stdin_readable_queued || !scr_stdin_nreadable) return; + scr_stdin_readable_queued = true; + scr_next_tick_raw(scr_stdin_emit_readable); +} + +/* Adding a 'readable' listener starts reading ahead to the 64 KiB mark; a + * buffer that already holds input fires the event at once (next tick). */ +static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { + if (scr_stdin_eof || scr_stdin_destroyed) { + scr_closure_release(cb); + return; + } + if (scr_stdin_nreadable == scr_stdin_readable_cap) { + scr_stdin_readable_cap = scr_stdin_readable_cap ? scr_stdin_readable_cap * 2 : 2; + scr_stdin_readable = realloc(scr_stdin_readable, scr_stdin_readable_cap * sizeof *scr_stdin_readable); + if (!scr_stdin_readable) scr_events_oom(); + } + scr_stdin_readable[scr_stdin_nreadable].cb = cb; + scr_stdin_readable[scr_stdin_nreadable].once = once; + scr_stdin_nreadable++; + scr_stdin_readable_need = true; + if (scr_stdin_read_demand < 65536) scr_stdin_read_demand = 65536; + if (scr_stdin_buffer && scr_stdin_buffer->len) scr_stdin_queue_readable(); +} + +/* The input ended: with 'readable' listeners the event fires once more and + * 'end' waits for the read that finds nothing left; otherwise 'end' fires + * once the buffer is drained. */ +static void scr_stdin_reached_eof(void) { + scr_stdin_read_eof = true; + if (scr_stdin_nreadable) scr_stdin_queue_readable(); + else if (!scr_stdin_buffer) scr_stdin_finish(true); +} + void scr_stdin_on_error(ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); @@ -528,6 +607,10 @@ static void scr_stdin_drop_listeners(void) { for (size_t i = 0; i < scr_stdin_ndata; i++) scr_closure_release(scr_stdin_data[i].cb); for (size_t i = 0; i < scr_stdin_nend; i++) scr_closure_release(scr_stdin_end[i].cb); for (size_t i = 0; i < scr_stdin_nerr; i++) scr_closure_release(scr_stdin_err[i].cb); + for (size_t i = 0; i < scr_stdin_nreadable; i++) scr_closure_release(scr_stdin_readable[i].cb); + free(scr_stdin_readable); + scr_stdin_readable = NULL; + scr_stdin_nreadable = scr_stdin_readable_cap = 0; free(scr_stdin_data); free(scr_stdin_end); free(scr_stdin_err); @@ -627,7 +710,8 @@ static void scr_stdin_deliver(ScrBytes *chunk); /* A chunk that arrived (moves): delivered, or buffered while paused or * without a consumer. */ static void scr_stdin_arrived(ScrBytes *chunk) { - if (scr_stdin_paused || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { + if (scr_stdin_paused || scr_stdin_nreadable || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { + if (scr_stdin_nreadable && scr_stdin_readable_need) scr_stdin_queue_readable(); if (scr_stdin_buffer) { size_t before = scr_stdin_buffer->len; ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + chunk->len)); @@ -645,7 +729,11 @@ static void scr_stdin_arrived(ScrBytes *chunk) { static void scr_stdin_service(void) { if (!scr_stdin_watching()) return; - if (!scr_stdin_paused && scr_stdin_buffer) { + /* Buffered input goes to a consumer: 'data' listeners, a resumed + * stream, or a parked for-await read. After the last 'readable' listener + * it stays buffered until one arrives, as in Node. */ + if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffer && + (scr_stdin_ndata > 0 || scr_stdin_resumed || scr_stdin_waiter != NULL)) { ScrBytes *chunk = scr_stdin_buffer; scr_stdin_buffer = NULL; scr_stdin_deliver(chunk); @@ -653,7 +741,7 @@ static void scr_stdin_service(void) { return; } if (scr_stdin_read_eof) { - if (!scr_stdin_buffer) scr_stdin_finish(true); + if (!scr_stdin_buffer && !scr_stdin_nreadable) scr_stdin_finish(true); return; } if (scr_stdin_fed) { @@ -662,10 +750,7 @@ static void scr_stdin_service(void) { bool ended = false; ScrBytes *chunk = scr_stdin_fed_take(&ended); if (chunk) scr_stdin_arrived(chunk); - else if (ended) { - scr_stdin_read_eof = true; - if (!scr_stdin_buffer) scr_stdin_finish(true); - } + else if (ended) scr_stdin_reached_eof(); return; } #ifdef _WIN32 @@ -727,8 +812,7 @@ static void scr_stdin_service(void) { return; } if (n == 0) { - scr_stdin_read_eof = true; - if (!scr_stdin_buffer) scr_stdin_finish(true); + scr_stdin_reached_eof(); return; } ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); @@ -802,6 +886,8 @@ static void scr_stdin_deliver(ScrBytes *chunk) { #endif ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; + /* Node's stdin yields Buffers. */ + chunk->is_buffer = true; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, scr_bytes_release_v, NULL); scr_promise_release(p); @@ -878,6 +964,7 @@ static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { } #endif static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } +static void scr_stdio_readable(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); ScrDyn *arg = scr_dyn_from_error(error); @@ -985,6 +1072,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { if (scr_stdin_read_eof && wanted > available) wanted = available; if (wanted == 0 || wanted > available) { if (!scr_stdin_eof && !scr_stdin_destroyed) { + scr_stdin_readable_need = true; scr_stdin_read_demand = wanted > 65536 ? wanted : 65536; if (scr_stdin_read_eof) scr_next_tick_raw(scr_stdio_finish_read); } @@ -995,6 +1083,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { scr_bytes_release(scr_stdin_buffer); scr_stdin_buffer = rest; scr_stdin_read_demand = 65536; + if (!rest && !scr_stdin_read_eof) scr_stdin_readable_need = true; scr_stdin_deliver(chunk); ScrDyn *result = scr_dyn_new_chunk(chunk); scr_bytes_release(chunk); @@ -1150,12 +1239,14 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; + bool readable = stream->fd == 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 8 && + memcmp(event, "readable", 8) == 0; #ifdef SCR_STDIO_EXTENDED bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && memcmp(event, "resize", 6) == 0; - if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { + if ((!data && !end && !error && !readable && !resize) || (stream->fd != 0 && !error && !resize)) { #else - if ((!data && !end && !error) || (stream->fd != 0 && !error)) { + if ((!data && !end && !error && !readable) || (stream->fd != 0 && !error)) { #endif result = scr_stdio_refusal("event"); goto done; @@ -1175,6 +1266,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); #endif if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); + if (readable) scr_stdin_on_readable(scr_stdio_callback(listener, (void *)scr_stdio_readable), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { #define REMOVE_STDIO_LISTENER(list, count, fire) \ @@ -1187,6 +1279,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } if (data) { REMOVE_STDIO_LISTENER(scr_stdin_data, scr_stdin_ndata, scr_stdio_data); } if (end) { REMOVE_STDIO_LISTENER(scr_stdin_end, scr_stdin_nend, scr_stdio_end); } + if (readable) { REMOVE_STDIO_LISTENER(scr_stdin_readable, scr_stdin_nreadable, scr_stdio_readable); } if (error) { REMOVE_STDIO_LISTENER(scr_stdin_err, scr_stdin_nerr, scr_stdio_error); } #undef REMOVE_STDIO_LISTENER } diff --git a/tests/corpus/7680-typed-array-search.ts b/tests/corpus/7680-typed-array-search.ts new file mode 100644 index 000000000..353736e09 --- /dev/null +++ b/tests/corpus/7680-typed-array-search.ts @@ -0,0 +1,63 @@ +// TypedArray.prototype.indexOf, lastIndexOf and includes on every numeric +// element kind: strict equality (NaN never found) for indexOf/lastIndexOf, +// SameValueZero (NaN found, -0 equals 0) for includes, and fromIndex as +// ToIntegerOrInfinity, counted from the end when negative. +const bytes = new Uint8Array([1, 2, 10, 3, 10, 255]); +console.log("u8", bytes.indexOf(10), bytes.indexOf(10, 3), bytes.indexOf(10, 5), bytes.lastIndexOf(10)); +console.log("u8 from", bytes.indexOf(10, -2), bytes.indexOf(10, -100), bytes.indexOf(1, Infinity), bytes.indexOf(10, 2.9)); +console.log("u8 last", bytes.lastIndexOf(10, 3), bytes.lastIndexOf(10, -3), bytes.lastIndexOf(1, -100), bytes.lastIndexOf(255, 99)); +console.log("u8 values", bytes.indexOf(256 + 10), bytes.indexOf(-246), bytes.indexOf(10.5), bytes.includes(255), bytes.includes(0)); +console.log("u8 includes from", bytes.includes(1, 1), bytes.includes(255, -1), bytes.includes(2, -5)); + +const clamped = new Uint8ClampedArray([0, 300, -5, 127.5]); +console.log("u8c", clamped.indexOf(255), clamped.indexOf(0, 1), clamped.lastIndexOf(0), clamped.indexOf(128)); + +const signed = new Int8Array([-1, 127, -128, 0]); +console.log("i8", signed.indexOf(-1), signed.indexOf(255), signed.indexOf(-128), signed.lastIndexOf(0)); + +const shorts = new Int16Array([5, -300, 5, 7]); +const ushorts = new Uint16Array([65535, 1, 65535]); +console.log("16", shorts.indexOf(-300), shorts.lastIndexOf(5), ushorts.indexOf(-1), ushorts.lastIndexOf(65535, 1)); + +const ints = new Int32Array([-2147483648, 0, 2147483647, 0]); +const uints = new Uint32Array([4294967295, 3]); +console.log("32", ints.indexOf(2147483647), ints.lastIndexOf(0), uints.indexOf(4294967295), uints.includes(-1)); + +const floats = new Float64Array([1.5, NaN, -0, 0, Infinity]); +console.log("f64", floats.indexOf(NaN), floats.lastIndexOf(NaN), floats.includes(NaN), floats.indexOf(0), floats.indexOf(-0)); +console.log("f64 more", floats.lastIndexOf(0), floats.indexOf(Infinity), floats.includes(-Infinity), floats.includes(NaN, 2)); + +const singles = new Float32Array([1.1, 0.5, NaN]); +console.log("f32", singles.indexOf(1.1), singles.indexOf(Math.fround(1.1)), singles.indexOf(0.5), singles.includes(NaN), singles.indexOf(NaN)); + +const empty = new Float64Array(0); +console.log("empty", empty.indexOf(0), empty.lastIndexOf(0), empty.includes(0), empty.includes(0, -1)); + +// Views search their own window of the buffer. +const buffer = new ArrayBuffer(16); +const whole = new Uint8Array(buffer); +whole.set([9, 9, 1, 2, 9, 3, 9, 9]); +const view = new Uint8Array(buffer, 2, 4); +const sub = whole.subarray(3, 6); +console.log("views", view.indexOf(9), view.lastIndexOf(9), view.indexOf(1), sub.indexOf(9), sub.includes(1), sub.lastIndexOf(3)); +const words = new Uint16Array(buffer, 2, 3); +console.log("word view", words.indexOf(0x0201), words.length); + +// Shared memory searches the same way. +const shared = new Int32Array(new SharedArrayBuffer(16)); +shared[1] = 7; +shared[3] = 7; +console.log("shared", shared.indexOf(7), shared.lastIndexOf(7), shared.includes(0), shared.indexOf(7, 2)); + +// A loop that finds every occurrence, as a line splitter does. +const text = new TextEncoder().encode("one\ntwo\n\nthree\n"); +const lines: number[] = []; +for (let at = text.indexOf(10); at !== -1; at = text.indexOf(10, at + 1)) lines.push(at); +console.log("lines", lines.join(",")); + +// A Buffer passed as a Uint8Array keeps Buffer's search: a number needle +// is taken as a byte, and a NaN byteOffset searches the whole buffer. +function searchView(view: Uint8Array): string { + return [view.indexOf(97 + 256), view.lastIndexOf(98, NaN), view.includes(353), view.indexOf(98, -1)].join(","); +} +console.log(searchView(Buffer.from("abcab")), searchView(new Uint8Array([97, 98, 99, 97, 98]))); diff --git a/tests/corpus/7681-typed-array-search-dynamic.mjs b/tests/corpus/7681-typed-array-search-dynamic.mjs new file mode 100644 index 000000000..643ba0cfa --- /dev/null +++ b/tests/corpus/7681-typed-array-search-dynamic.mjs @@ -0,0 +1,28 @@ +// TypedArray search on untyped values (parameters of untyped functions and +// object members): needles that are not numbers are never found (strict +// equality), fromIndex may be undefined, a string or a fraction, and +// Buffers keep their own indexOf, which wraps number needles and takes +// strings. +function views(bytes, floats, shorts) { + return { bytes, floats, shorts }; +} +const { bytes, floats, shorts } = views( + new Uint8Array([1, 2, 10, 3, 10]), + new Float64Array([0.5, NaN, -0]), + new Int16Array([-1, 300, -1]), +); +console.log(bytes.indexOf(10), bytes.indexOf(10, 3), bytes.lastIndexOf(10), bytes.includes(3), bytes.includes(4)); +console.log(bytes.lastIndexOf(1, undefined), bytes.lastIndexOf(10), bytes.indexOf(10, undefined), bytes.includes(1, undefined)); +console.log(bytes.indexOf(10, '3'), bytes.indexOf(10, 2.9), bytes.indexOf(10, -1), bytes.lastIndexOf(10, -2), bytes.indexOf(10, null)); +console.log(bytes.indexOf('10'), bytes.includes('1'), bytes.indexOf(10n), bytes.indexOf(), bytes.includes(), bytes.indexOf(266)); +console.log(floats.indexOf(NaN), floats.includes(NaN), floats.indexOf(0), floats.lastIndexOf(-0), floats.includes(0.5, 1)); +console.log(shorts.indexOf(-1), shorts.lastIndexOf(-1), shorts.indexOf(300, -2), shorts.includes(65535)); +const holder = { view: bytes.subarray(2) }; +console.log(holder.view.indexOf(10), holder.view.lastIndexOf(10), holder.view.includes(1)); +const buffer = Buffer.from([1, 2, 10, 3, 10]); +const boxed = [buffer]; +console.log(boxed[0].indexOf(10), boxed[0].indexOf(266), boxed[0].indexOf('\n'), boxed[0].includes(Buffer.from([3, 10]))); +console.log(boxed[0].subarray(3).indexOf(10), Buffer.isBuffer(boxed[0].subarray(3)), boxed[0].lastIndexOf(10, -2)); +let count = 0; +for (let at = bytes.indexOf(10); at !== -1; at = bytes.indexOf(10, at + 1)) count++; +console.log('count', count); diff --git a/tests/corpus/7682-constructor-function-globals.js b/tests/corpus/7682-constructor-function-globals.js new file mode 100644 index 000000000..58292e4ac --- /dev/null +++ b/tests/corpus/7682-constructor-function-globals.js @@ -0,0 +1,55 @@ +// Module bindings that hold `new F()` of an ordinary constructor function +// (const, let and var), read and reassigned by functions declared elsewhere +// in the module, such as the head and tail of a linked list. +'use strict'; +function Segment(prev) { + this.prev = prev; + this.next = null; + this.data = new Array(4); + this.pos = 0; +} +const head = new Segment(); +let current = head; +var spare = new Segment(head); + +function append(value) { + if (current.pos === current.data.length) { + current = current.next || (current.next = new Segment(current)); + } + current.data[current.pos++] = value; +} + +function collect() { + const out = []; + for (let node = head; node; node = node.next) out.push(node.data.slice(0, node.pos).join('')); + return out.join('|'); +} + +function reset() { + current = head; + for (let node = head; node; node = node.next) node.pos = 0; +} + +for (const ch of 'abcdefghij') append(ch); +console.log(collect(), current === head, current.prev === head.next ? 'two' : 'more'); +reset(); +append('z'); +console.log(collect(), current === head, head.next !== null); +console.log(spare.prev === head, spare instanceof Segment, typeof spare.data); +spare = new Segment(spare); +console.log(spare.prev.prev === head); + +function Counter(start) { + this.value = start; +} +Counter.prototype.bump = function () { + return ++this.value; +}; +let counter = new Counter(10); +function bumpTwice() { + counter.bump(); + return counter.bump(); +} +console.log(bumpTwice(), counter.value); +counter = new Counter(0); +console.log(bumpTwice()); diff --git a/tests/fixtures/event-loop/stdin-for-await-buffers.mjs b/tests/fixtures/event-loop/stdin-for-await-buffers.mjs new file mode 100644 index 000000000..d64ae460a --- /dev/null +++ b/tests/fixtures/event-loop/stdin-for-await-buffers.mjs @@ -0,0 +1,10 @@ +// for await over process.stdin yields Buffers: Buffer.isBuffer holds and +// indexOf takes a byte and a start offset (only chunking-independent facts +// are printed). +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +console.log('buffers', chunks.every((chunk) => Buffer.isBuffer(chunk))); +const all = Buffer.concat(chunks); +const newlines = []; +for (let at = all.indexOf(10); at !== -1; at = all.indexOf(10, at + 1)) newlines.push(at); +console.log('newlines', newlines.join(','), all.subarray(newlines[0] + 1).indexOf('#')); diff --git a/tests/fixtures/event-loop/stdio-readable-large-size.mjs b/tests/fixtures/event-loop/stdio-readable-large-size.mjs new file mode 100644 index 000000000..f1f44edce --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-large-size.mjs @@ -0,0 +1,9 @@ +// read(size) above the 64 KiB read-ahead mark: 'readable' keeps firing as +// input arrives until the requested size is buffered. +const input = process.stdin; +const sizes = []; +input.on('readable', () => { + let chunk; + while ((chunk = input.read(150000)) !== null) sizes.push(chunk.length); +}); +input.on('end', () => console.log(sizes.join(','))); diff --git a/tests/fixtures/event-loop/stdio-readable-once-rest.mjs b/tests/fixtures/event-loop/stdio-readable-once-rest.mjs new file mode 100644 index 000000000..81eae144b --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-once-rest.mjs @@ -0,0 +1,12 @@ +// A once('readable') listener that reads part of the input: the rest stays +// buffered for a 'data' listener added afterwards. +const input = process.stdin; +input.once('readable', () => { + const head = input.read(3); + console.log('head', head === null ? null : head.toString('ascii')); + setTimeout(() => { + const rest = []; + input.on('data', (chunk) => rest.push(chunk.toString('ascii'))); + input.on('end', () => console.log('rest', rest.join(''))); + }, 50); +}); diff --git a/tests/fixtures/event-loop/stdio-readable-size.mjs b/tests/fixtures/event-loop/stdio-readable-size.mjs new file mode 100644 index 000000000..9ff76dafe --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-size.mjs @@ -0,0 +1,18 @@ +// Fixed-size reads of stdin: read(n) answers null until n bytes are +// buffered, and the rest once the input ended. +const input = process.stdin; +const chunks = []; +const counts = []; +input.on('readable', () => { + let reads = 0; + let chunk; + while ((chunk = input.read(4)) !== null) { + chunks.push(chunk.toString('ascii')); + reads++; + } + counts.push(reads); +}); +input.on('end', () => { + console.log(chunks.join('|')); + console.log(counts.join(',')); +}); diff --git a/tests/fixtures/event-loop/stdio-readable.mjs b/tests/fixtures/event-loop/stdio-readable.mjs new file mode 100644 index 000000000..324a4b57b --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable.mjs @@ -0,0 +1,19 @@ +// process.stdin in paused mode through 'readable': each event drains the +// buffer with read(), one more event follows EOF, and 'end' comes after the +// read that finds the buffer empty. +const input = process.stdin; +const events = []; +let text = ''; +input.on('readable', () => { + let reads = 0; + let chunk; + while ((chunk = input.read()) !== null) { + text += String(chunk); + reads++; + } + events.push(`readable ${reads}`); +}); +input.on('end', () => { + console.log(events.join(', ')); + console.log(JSON.stringify(text), input.readableLength); +}); diff --git a/tests/harness/corpus-costs/7680-typed-array-search.ts.json b/tests/harness/corpus-costs/7680-typed-array-search.ts.json new file mode 100644 index 000000000..37f8b6e89 --- /dev/null +++ b/tests/harness/corpus-costs/7680-typed-array-search.ts.json @@ -0,0 +1 @@ +{ "plain": 2204, "san": 3060 } diff --git a/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json b/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json new file mode 100644 index 000000000..b6fcd5eff --- /dev/null +++ b/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 1649, "san": 958 } diff --git a/tests/harness/corpus-costs/7682-constructor-function-globals.js.json b/tests/harness/corpus-costs/7682-constructor-function-globals.js.json new file mode 100644 index 000000000..90fcede92 --- /dev/null +++ b/tests/harness/corpus-costs/7682-constructor-function-globals.js.json @@ -0,0 +1 @@ +{ "plain": 1623, "san": 962 } diff --git a/tests/harness/event-loop-cases.ts b/tests/harness/event-loop-cases.ts index 40bc7988d..1b75defaa 100644 --- a/tests/harness/event-loop-cases.ts +++ b/tests/harness/event-loop-cases.ts @@ -34,6 +34,55 @@ export const eventLoopCases: EventLoopCase[] = [ fixture: "stdio-paused-exit.mjs", script: { writes: [], end: false }, }, + { + title: "stdio values: 'readable' drains with read(), then fires once more at EOF", + fixture: "stdio-readable.mjs", + script: { + writes: [ + { delayMs: 0, data: "alpha " }, + { delayMs: 60, data: "beta\n" }, + ], + end: true, + }, + }, + { + title: "stdio values: 'readable' with read(size) holds a partial tail until EOF", + fixture: "stdio-readable-size.mjs", + script: { writes: [{ delayMs: 0, data: "abcdefghij" }], end: true }, + }, + { + title: "stdio values: input a once('readable') listener leaves stays buffered for 'data'", + fixture: "stdio-readable-once-rest.mjs", + script: { writes: [{ delayMs: 0, data: "abcdefgh" }], end: true }, + }, + { + title: "stdio values: 'readable' with a read(size) above the read-ahead mark", + fixture: "stdio-readable-large-size.mjs", + script: { + writes: [ + { delayMs: 0, data: "a".repeat(70000) }, + { delayMs: 30, data: "b".repeat(70000) }, + { delayMs: 30, data: "c".repeat(60000) }, + ], + end: true, + }, + }, + { + title: "stdio values: 'readable' on an immediately ended pipe", + fixture: "stdio-readable.mjs", + script: { writes: [], end: true }, + }, + { + title: "for-await: process.stdin yields Buffers", + fixture: "stdin-for-await-buffers.mjs", + script: { + writes: [ + { delayMs: 0, data: "#one\nabcd\n" }, + { delayMs: 40, data: "#two\nefgh\n" }, + ], + end: true, + }, + }, { title: "read-all: one instant write, then EOF", fixture: "stdin-read-all.ts", From 2c44d3e3abe229cab1062e29b23dbe1bfc781948 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 05:15:45 -0500 Subject: [PATCH 02/11] Stop reading stdin at the read-ahead mark while 'readable' listeners hold it - With 'readable' listeners the stream stays paused, as in Node: 'data' listeners, resume() and a parked for-await read no longer keep the loop reading once the held input reaches the 64 KiB mark (or a larger read(size)); reading resumes when a read drains it. - Held input is a list of chunks with a running length, so an arrival appends without copying what is already held, and read() joins only the bytes it returns. - New event-loop case: 'readable', 'data' and resume() with no read() for 400 ms while 1 MiB arrives. --- packages/runtime/src/scr_events.c | 123 +++++++++++++----- .../stdio-readable-backpressure.mjs | 27 ++++ tests/harness/event-loop-cases.ts | 11 ++ 3 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/event-loop/stdio-readable-backpressure.mjs diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index bc554a445..6e0bf5146 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -388,7 +388,12 @@ static SCR_TL bool scr_stdin_destroyed = false; static SCR_TL bool scr_stdin_paused = false; static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; -static SCR_TL ScrBytes *scr_stdin_buffer; +/* Input held for read() and later consumers, as Node's BufferList: the + * chunks in arrival order and their total length, so an arrival appends + * without copying what is already held. */ +static SCR_TL ScrBytes **scr_stdin_held = NULL; +static SCR_TL size_t scr_stdin_nheld = 0, scr_stdin_held_cap = 0; +static SCR_TL size_t scr_stdin_buffered = 0; static SCR_TL size_t scr_stdin_read_demand; /* 'readable' listeners (paused mode, as Node's once one is added): input * stays buffered for read(), and the event fires on the next tick when @@ -398,6 +403,66 @@ static SCR_TL size_t scr_stdin_nreadable = 0, scr_stdin_readable_cap = 0; static SCR_TL bool scr_stdin_readable_need = false; static SCR_TL bool scr_stdin_readable_queued = false; +/* Appends an arrived chunk (moves) to the held input. */ +static void scr_stdin_hold(ScrBytes *chunk) { + if (chunk->len == 0) { + scr_bytes_release(chunk); + return; + } + if (scr_stdin_nheld == scr_stdin_held_cap) { + scr_stdin_held_cap = scr_stdin_held_cap ? scr_stdin_held_cap * 2 : 4; + scr_stdin_held = realloc(scr_stdin_held, scr_stdin_held_cap * sizeof *scr_stdin_held); + if (!scr_stdin_held) scr_events_oom(); + } + scr_stdin_held[scr_stdin_nheld++] = chunk; + scr_stdin_buffered += chunk->len; +} + +/* The first n held bytes as one chunk (+1), 0 < n <= scr_stdin_buffered. + * A whole first chunk moves out without a copy; otherwise the bytes are + * joined once, and a partly taken chunk keeps its rest. */ +static ScrBytes *scr_stdin_take(size_t n) { + ScrBytes *first = scr_stdin_held[0]; + size_t used = 0; + ScrBytes *out; + if (n == first->len) { + out = first; + used = 1; + } else if (n < first->len) { + out = scr_bytes_slice(first, 0, (double)n); + scr_stdin_held[0] = scr_bytes_slice(first, (double)n, (double)first->len); + scr_bytes_release(first); + } else { + out = scr_bytes_new(SCR_BYTES_U8, (double)n); + size_t at = 0; + while (at < n) { + ScrBytes *chunk = scr_stdin_held[used]; + size_t part = chunk->len <= n - at ? chunk->len : n - at; + memcpy(out->data + at, chunk->data, part); + at += part; + if (part == chunk->len) { + scr_bytes_release(chunk); + used++; + } else { + scr_stdin_held[used] = scr_bytes_slice(chunk, (double)part, (double)chunk->len); + scr_bytes_release(chunk); + } + } + } + memmove(scr_stdin_held, scr_stdin_held + used, (scr_stdin_nheld - used) * sizeof *scr_stdin_held); + scr_stdin_nheld -= used; + scr_stdin_buffered -= n; + return out; +} + +static void scr_stdin_drop_held(void) { + for (size_t i = 0; i < scr_stdin_nheld; i++) scr_bytes_release(scr_stdin_held[i]); + free(scr_stdin_held); + scr_stdin_held = NULL; + scr_stdin_nheld = scr_stdin_held_cap = 0; + scr_stdin_buffered = 0; +} + #ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until * set) and the StringDecoder's packed pending partial sequence, so a @@ -486,7 +551,7 @@ static void scr_stdin_finish(bool fire_end); static void scr_stdin_emit_readable(void) { scr_stdin_readable_queued = false; - size_t length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + size_t length = scr_stdin_buffered; if (scr_stdin_eof || scr_stdin_destroyed || (!length && !scr_stdin_read_eof)) return; size_t n = scr_stdin_nreadable; ScrStdinEndL *snap = malloc((n ? n : 1) * sizeof *snap); @@ -512,7 +577,7 @@ static void scr_stdin_emit_readable(void) { scr_closure_release(snap[i].cb); } free(snap); - length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + length = scr_stdin_buffered; /* Another event is due once the buffer reaches the 64 KiB mark, or the * larger size a listener's read(size) asked for. */ scr_stdin_readable_need = @@ -542,7 +607,7 @@ static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { scr_stdin_nreadable++; scr_stdin_readable_need = true; if (scr_stdin_read_demand < 65536) scr_stdin_read_demand = 65536; - if (scr_stdin_buffer && scr_stdin_buffer->len) scr_stdin_queue_readable(); + if (scr_stdin_buffered) scr_stdin_queue_readable(); } /* The input ended: with 'readable' listeners the event fires once more and @@ -551,7 +616,7 @@ static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { static void scr_stdin_reached_eof(void) { scr_stdin_read_eof = true; if (scr_stdin_nreadable) scr_stdin_queue_readable(); - else if (!scr_stdin_buffer) scr_stdin_finish(true); + else if (!scr_stdin_buffered) scr_stdin_finish(true); } void scr_stdin_on_error(ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once) { @@ -595,10 +660,15 @@ bool scr_stdin_ended(void) { return scr_stdin_eof || scr_stdin_destroyed; } * alive (a paused Node stdin with only those never flows — the process * exits with them registered). */ static bool scr_stdin_watching(void) { - return !scr_stdin_eof && !scr_stdin_destroyed && - ((!scr_stdin_paused && (scr_stdin_ndata > 0 || scr_stdin_resumed)) || - scr_stdin_waiter != NULL || - (!scr_stdin_read_eof && scr_stdin_read_demand > (scr_stdin_buffer ? scr_stdin_buffer->len : 0))); + if (scr_stdin_eof || scr_stdin_destroyed) return false; + /* 'readable' listeners keep the stream paused, as in Node, where + * resume() and 'data' listeners do not make it flow: it reads ahead only + * while a read wants more than is held (the 64 KiB mark, or a larger + * read(size)), and stops until something drains it. */ + if (scr_stdin_nreadable) return !scr_stdin_read_eof && scr_stdin_read_demand > scr_stdin_buffered; + return (!scr_stdin_paused && (scr_stdin_ndata > 0 || scr_stdin_resumed)) || + scr_stdin_waiter != NULL || + (!scr_stdin_read_eof && scr_stdin_read_demand > scr_stdin_buffered); } bool scr_stdin_pending(void) { return scr_stdin_watching(); } @@ -676,8 +746,7 @@ static void scr_stdin_finish(bool fire_end) { void scr_stdin_destroy(void) { if (scr_stdin_destroyed) return; scr_stdin_destroyed = true; - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = NULL; + scr_stdin_drop_held(); scr_stdin_read_demand = 0; scr_stdin_settle_done(); scr_stdin_drop_listeners(); @@ -712,15 +781,7 @@ static void scr_stdin_deliver(ScrBytes *chunk); static void scr_stdin_arrived(ScrBytes *chunk) { if (scr_stdin_paused || scr_stdin_nreadable || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { if (scr_stdin_nreadable && scr_stdin_readable_need) scr_stdin_queue_readable(); - if (scr_stdin_buffer) { - size_t before = scr_stdin_buffer->len; - ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + chunk->len)); - memcpy(joined->data, scr_stdin_buffer->data, before); - memcpy(joined->data + before, chunk->data, chunk->len); - scr_bytes_release(scr_stdin_buffer); - scr_bytes_release(chunk); - scr_stdin_buffer = joined; - } else scr_stdin_buffer = chunk; + scr_stdin_hold(chunk); return; } scr_stdin_deliver(chunk); @@ -732,16 +793,15 @@ static void scr_stdin_service(void) { /* Buffered input goes to a consumer: 'data' listeners, a resumed * stream, or a parked for-await read. After the last 'readable' listener * it stays buffered until one arrives, as in Node. */ - if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffer && + if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffered && (scr_stdin_ndata > 0 || scr_stdin_resumed || scr_stdin_waiter != NULL)) { - ScrBytes *chunk = scr_stdin_buffer; - scr_stdin_buffer = NULL; + ScrBytes *chunk = scr_stdin_take(scr_stdin_buffered); scr_stdin_deliver(chunk); scr_bytes_release(chunk); return; } if (scr_stdin_read_eof) { - if (!scr_stdin_buffer && !scr_stdin_nreadable) scr_stdin_finish(true); + if (!scr_stdin_buffered && !scr_stdin_nreadable) scr_stdin_finish(true); return; } if (scr_stdin_fed) { @@ -1058,7 +1118,7 @@ static void scr_stdio_queue_write_error(ScrStdio *stream, ScrDyn *callback, int } static void scr_stdio_finish_read(void) { - if (scr_stdin_read_eof && !scr_stdin_buffer && !scr_stdin_eof && !scr_stdin_destroyed) + if (scr_stdin_read_eof && !scr_stdin_buffered && !scr_stdin_eof && !scr_stdin_destroyed) scr_stdin_finish(true); } @@ -1067,7 +1127,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { if (!all && (size->kind != SCR_DYN_NUM || !isfinite(size->v.num) || size->v.num < 0 || size->v.num > 1073741824 || floor(size->v.num) != size->v.num)) return scr_stdio_refusal("read size"); - size_t available = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + size_t available = scr_stdin_buffered; size_t wanted = all ? available : (size_t)size->v.num; if (scr_stdin_read_eof && wanted > available) wanted = available; if (wanted == 0 || wanted > available) { @@ -1078,10 +1138,8 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { } return scr_dyn_new_null(); } - ScrBytes *chunk = scr_bytes_slice(scr_stdin_buffer, 0, (double)wanted); - ScrBytes *rest = wanted < available ? scr_bytes_slice(scr_stdin_buffer, (double)wanted, (double)available) : NULL; - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = rest; + ScrBytes *chunk = scr_stdin_take(wanted); + bool rest = scr_stdin_buffered > 0; scr_stdin_read_demand = 65536; if (!rest && !scr_stdin_read_eof) scr_stdin_readable_need = true; scr_stdin_deliver(chunk); @@ -1339,7 +1397,7 @@ static ScrDyn *scr_stdio_get(void *ptr, const char *key, size_t length) { return dimension < 0 ? scr_dyn_retain(scr_dyn_undefined()) : scr_dyn_new_num(dimension); } if (stream->fd == 0 && length == 14 && memcmp(key, "readableLength", 14) == 0) - return scr_dyn_new_num(scr_stdin_buffer ? (double)scr_stdin_buffer->len : 0); + return scr_dyn_new_num((double)scr_stdin_buffered); if (stream->fd == 0 && length == 5 && memcmp(key, "isRaw", 5) == 0) return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_stdio_raw) : scr_dyn_retain(scr_dyn_undefined()); if (stream->fd == 0 && length == 9 && memcmp(key, "destroyed", 9) == 0) return scr_dyn_new_bool(scr_stdin_destroyed); @@ -1624,8 +1682,7 @@ static void scr_events_cleanup_atexit(void) { if (scr_context_is_main()) scr_sig_cleanup(); scr_stdin_settle_done(); scr_stdin_drop_listeners(); - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = NULL; + scr_stdin_drop_held(); if (scr_stdio_initialized) scr_stdio_cleanup(); } diff --git a/tests/fixtures/event-loop/stdio-readable-backpressure.mjs b/tests/fixtures/event-loop/stdio-readable-backpressure.mjs new file mode 100644 index 000000000..df2afb18f --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-backpressure.mjs @@ -0,0 +1,27 @@ +// 'readable', 'data' and resume() together, with no read() for a while: +// 'readable' keeps the stream paused, so it stops reading at its read-ahead +// mark until a read drains it, and 'data' sees what read() returns. +const input = process.stdin; +let draining = false; +let peak = 0; +let dataBytes = 0; +let readBytes = 0; +const drain = () => { + let chunk; + while ((chunk = input.read()) !== null) readBytes += chunk.length; +}; +input.on('data', (chunk) => { + dataBytes += chunk.length; +}); +input.on('readable', () => { + peak = Math.max(peak, input.readableLength); + if (draining) drain(); +}); +input.resume(); +setTimeout(() => { + peak = Math.max(peak, input.readableLength); + console.log('held below 128 KiB while not draining', peak <= 131072); + draining = true; + drain(); +}, 400); +input.on('end', () => console.log('read', readBytes, 'data', dataBytes)); diff --git a/tests/harness/event-loop-cases.ts b/tests/harness/event-loop-cases.ts index 1b75defaa..50a7d2b85 100644 --- a/tests/harness/event-loop-cases.ts +++ b/tests/harness/event-loop-cases.ts @@ -67,6 +67,17 @@ export const eventLoopCases: EventLoopCase[] = [ end: true, }, }, + { + title: "stdio values: 'readable' with 'data' and resume() holds input at the read-ahead mark", + fixture: "stdio-readable-backpressure.mjs", + script: { + writes: Array.from({ length: 16 }, (_, i) => ({ + delayMs: i === 0 ? 0 : 5, + data: String.fromCharCode(97 + i).repeat(65536), + })), + end: true, + }, + }, { title: "stdio values: 'readable' on an immediately ended pipe", fixture: "stdio-readable.mjs", From b80666adb4376215dc507991e942da0a3b0a339a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:40:07 -0500 Subject: [PATCH 03/11] Keep null and undefined through non-null assertions into nullable destinations JavaScript erases `x!`. scriptc lowered it on a union as a checked narrowing to the asserted type, so a value that still held null or undefined threw a TypeError, even where it went straight into a return value or argument whose type holds it: `return cache.get(key)!` from a function that returns `T | null`, with null stored in the map, threw where Node returns null, and `String(names.get(key)!)` threw where Node returns "null". A non-null assertion that is a returned value, an arrow function's body, or a call or new argument now keeps the value when the destination type is any or unknown, or holds the null or undefined the assertion removes; the destination's coercion re-tags it. Bindings and assignments keep the checked narrowing, because the checker narrows them to the asserted type for later reads. The native compiler hit this in the bytes-loop versioning analysis, which caches null results that way (`if (shapes.has(e)) return shapes.get(e)!;`): it refused programs whose counted loop reads or writes several typed arrays at one masked index with SC3004, including tsc-ts's release build, which the Node-built compiler compiled. typed-array-masked-index-stores.ts is such a program; the LLVM emitter self-hosting test now emits and runs it with the natively built emitter. --- .../src/frontend/lowering/lower-exprs.ts | 38 +++++++++++++++ ...null-assertion-nullish-destination.ts.json | 6 +++ .../typed-array-masked-index-stores.ts.json | 6 +++ ...-non-null-assertion-nullish-destination.ts | 47 +++++++++++++++++++ .../corpus/typed-array-masked-index-stores.ts | 40 ++++++++++++++++ ...null-assertion-nullish-destination.ts.json | 1 + .../typed-array-masked-index-stores.ts.json | 1 + .../harness/self-hosting-llvm-emitter.test.ts | 15 ++++-- 8 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json create mode 100644 tests/corpus/7189-non-null-assertion-nullish-destination.ts create mode 100644 tests/corpus/typed-array-masked-index-stores.ts create mode 100644 tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json create mode 100644 tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c1c5ef8c8..4f49428bc 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -426,6 +426,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { return inner; const use = runtimeOptionalUseOf(expr); if (runtimeOptionalAssertionErases(lowerer, expr, inner, use)) return inner; + // A destination that holds null or undefined keeps the value, as `!` + // does in JavaScript (`return cache.get(key)!` from a function that + // returns `T | null`); the destination's own coercion re-tags it. + if (assertionDestinationHoldsNullish(lowerer, expr, inner.type)) return inner; if (target && target.kind !== "union" && !typeEquals(target, inner.type)) { const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc); if (helper) { @@ -933,6 +937,40 @@ export function assertedElementKeepsUndefined( ); } +/** Whether `node!` is a returned value, an arrow function's body or a call + * argument whose destination type (the return or parameter type) is `any` + * or `unknown` (as for `String(x!)` and `Number(x!)`) or holds null or + * undefined that the union `inner` holds and the assertion would remove. + * Bindings and assignments are left out: the checker narrows them to the + * asserted type for later reads. */ +function assertionDestinationHoldsNullish( + lowerer: Lowerer, + node: ts.NonNullExpression, + inner: IrType & { kind: "union" }, +): boolean { + const holdsNull = lowerer.armTag(inner.unionId, NULL_T) >= 0; + const holdsUndefined = lowerer.armTag(inner.unionId, UNDEFINED_T) >= 0; + if (!holdsNull && !holdsUndefined) return false; + let outer: ts.Expression = node; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + const destination = + (ts.isReturnStatement(parent) && parent.expression === outer) || + (ts.isArrowFunction(parent) && parent.body === outer) || + ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && + parent.arguments?.nodes.includes(outer) === true); + if (!destination) return false; + const contextual = lowerer.checker.getContextualType(outer); + if (!contextual) return false; + const nullish = + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + (holdsNull ? ts.TypeFlags.Null : 0) | + (holdsUndefined ? ts.TypeFlags.Undefined : 0); + if ((contextual.flags & ts.TypeFlags.Union) === 0) return (contextual.flags & nullish) !== 0; + return constituentTypes(contextual).some((type) => (type.flags & nullish) !== 0); +} + export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json new file mode 100644 index 000000000..dc0d277ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7189-non-null-assertion-nullish-destination.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json new file mode 100644 index 000000000..dde5a96fa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/typed-array-masked-index-stores.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7189-non-null-assertion-nullish-destination.ts b/tests/corpus/7189-non-null-assertion-nullish-destination.ts new file mode 100644 index 000000000..0386e0713 --- /dev/null +++ b/tests/corpus/7189-non-null-assertion-nullish-destination.ts @@ -0,0 +1,47 @@ +// `!` has no runtime effect: a value asserted non-null that still holds +// null or undefined reaches a return value, an arrow function's result or +// an argument whose declared type holds it (or is any or unknown), unchanged. +interface Shape { + size: number; +} + +const shapes = new Map(); +function shapeOf(key: string): Shape | null { + if (shapes.has(key)) return shapes.get(key)!; + const result = key.length > 3 ? { size: key.length } : null; + shapes.set(key, result); + return result; +} +console.log(shapeOf("ab"), shapeOf("abcd")); +console.log(shapeOf("ab"), shapeOf("abcd"), shapes.size); + +const counts = new Map([["a", 1]]); +function countOf(key: string): number | undefined { + return counts.get(key)!; +} +console.log(countOf("a"), countOf("b")); + +function describe(shape: Shape | null): string { + return shape === null ? "none" : `size ${shape.size}`; +} +console.log(describe(shapes.get("ab")!), describe(shapes.get("abcd")!)); + +class Slot { + readonly label: string; + constructor(shape: Shape | null) { + this.label = shape === null ? "empty" : `slot ${shape.size}`; + } +} +console.log(new Slot(shapes.get("ab")!).label, new Slot(shapes.get("abcd")!).label); + +const lookup = (key: string): Shape | null => shapes.get(key)!; +console.log(lookup("ab"), lookup("abcd")?.size); + +const names = new Map([["a", null], ["b", "bee"]]); +const amounts = new Map([["a", undefined], ["b", 2]]); +function kindOf(value: unknown): string { + return value === null ? "null" : typeof value; +} +console.log(String(names.get("a")!), String(names.get("b")!)); +console.log(Number(amounts.get("a")!), Number(amounts.get("b")!)); +console.log(kindOf(names.get("a")!), kindOf(amounts.get("a")!), kindOf(names.get("b")!)); diff --git a/tests/corpus/typed-array-masked-index-stores.ts b/tests/corpus/typed-array-masked-index-stores.ts new file mode 100644 index 000000000..88a25b4c5 --- /dev/null +++ b/tests/corpus/typed-array-masked-index-stores.ts @@ -0,0 +1,40 @@ +// Several typed array accesses in one counted loop share an index that is +// not an affine function of the counter (a masked or remaindered counter). +// The loop versioning analysis records that such an index has no affine +// shape and reads the recorded result again at each later access. +const length = 64; +const bytes = new Uint8Array(length); +const words = new Int32Array(16); +const totals = new Float64Array(16); + +for (let i = 0; i < bytes.length; i++) { + const slot = i & 15; + words[slot] = words[slot]! + i; + totals[slot] = totals[slot]! + i * 0.5; + bytes[i] = slot; +} +console.log(Array.from(words).join(",")); +console.log(Array.from(totals).join(",")); +console.log(bytes.reduce((sum, value) => sum + value, 0)); + +const ring = new Uint16Array(8); +const seen = new Uint8Array(8); +for (let i = 0; i < length; i++) { + const at = i % 8; + ring[at] = ring[at]! * 3 + i; + seen[at] = seen[at]! + 1; +} +console.log(Array.from(ring).join(","), Array.from(seen).join(",")); + +function scatter(source: Uint8Array, mask: number): Uint32Array { + const out = new Uint32Array(mask + 1); + const counts = new Uint32Array(mask + 1); + for (let i = 0; i < source.length; i++) { + const bucket = (source[i]! + i) & mask; + out[bucket] = out[bucket]! + source[i]!; + counts[bucket] = counts[bucket]! + 1; + } + for (let i = 0; i < out.length; i++) out[i] = out[i]! * 10 + counts[i]!; + return out; +} +console.log(Array.from(scatter(bytes, 7)).join(",")); diff --git a/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json b/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json new file mode 100644 index 000000000..7473d65aa --- /dev/null +++ b/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json @@ -0,0 +1 @@ +{ "plain": 2653, "san": 3208 } diff --git a/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json b/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json new file mode 100644 index 000000000..3e45ee566 --- /dev/null +++ b/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json @@ -0,0 +1 @@ +{ "plain": 2791, "san": 3186 } diff --git a/tests/harness/self-hosting-llvm-emitter.test.ts b/tests/harness/self-hosting-llvm-emitter.test.ts index cb9cd69c4..a81bac16c 100644 --- a/tests/harness/self-hosting-llvm-emitter.test.ts +++ b/tests/harness/self-hosting-llvm-emitter.test.ts @@ -92,6 +92,7 @@ const programs = [ "local-union-storage.ts", "class-callback-storage.ts", "byte-record-windows.ts", + "typed-array-masked-index-stores.ts", "constant-callback-dispatch.ts", "constant-number-remainders.ts", "numeric-call-specialization.ts", @@ -99,6 +100,10 @@ const programs = [ "native-worker-roots/main.ts", "native-worker-shared-views.ts", ]; +// Debug info turns the emitter's loop optimizations off. These programs are +// emitted without it, so the compiled emitter runs its loop analyses on them +// (typed-array loop versioning, which caches null index shapes). +const withoutDebugInfo = new Set(["typed-array-masked-index-stores.ts"]); for (const backend of ["llvm"] as const) { test( @@ -227,10 +232,12 @@ for (const backend of ["llvm"] as const) { if (!lowered.ok) throw new Error(`${source}: ${lowered.diagnostics.map((d) => d.message).join("\n")}`); const mod = deserializeModule(readFileSync(irPath, "utf8")); - const request: LlvmEmitterRequest = llvmEmitterRequest({ - debug: true, - sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], - }); + const request: LlvmEmitterRequest = withoutDebugInfo.has(source) + ? llvmEmitterRequest() + : llvmEmitterRequest({ + debug: true, + sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], + }); const text = await emit(mod, source, request); const llvmPath = join(dir, "program.ll"); const outPath = executable("program"); From 09f034264104d527d30d0e396da78c8105d38a85 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:40:35 -0700 Subject: [PATCH 04/11] Keep an error's name and message where a JavaScript factory created them zValidator's default failure hook serializes zod 4's ZodError with `name` after `message` natively, and with the first `name` zod's factory assigns ("$ZodError") when the second assignment took the typed slot. ZodError instances are views of a JavaScript class extending Error: each view rebuilds `message` and `name` from the error slots at fixed positions (after `stack`, and after every other property), and the property bag that holds the dynamically added properties copied its committed, possibly stale, values over them. A JavaScript Error subclass with a property bag now marks its class (errorBagOrder), and its view calls scr_error_dyn_follow_bag after the bag is copied: `name` and `message` move to follow the property they followed when the bag was committed, and data values come from the slots. TypeScript classes keep their views. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/expr-stream-bridges.ts | 19 +++++ .../lowering/class-dynamic-dispatch.ts | 9 +++ packages/compiler/src/ir/ir.ts | 4 + .../8112-error-factory-own-key-order.js.json | 6 ++ .../cases/hono-zod-default-hook/main.ts.json | 61 ++++++++++++++++ packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_json.c | 57 +++++++++++++++ .../8112-error-factory-own-key-order.js | 73 +++++++++++++++++++ .../npm/cases/hono-zod-default-hook/main.ts | 38 ++++++++++ .../cases/hono-zod-default-hook/package.json | 1 + .../8112-error-factory-own-key-order.js.json | 1 + 11 files changed, 273 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json create mode 100644 tests/corpus/8112-error-factory-own-key-order.js create mode 100644 tests/fixtures/npm/cases/hono-zod-default-hook/main.ts create mode 100644 tests/fixtures/npm/cases/hono-zod-default-hook/package.json create mode 100644 tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 1acc75cec..d6d5a5b7c 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -993,6 +993,25 @@ export function streamTypedRefMaterializeAdapter( : `call void @scr_error_dyn_place_name(ptr ${out}, ptr ${host.cstr(nameAfter)}, ${host.sizeType} ${Buffer.byteLength(nameAfter, "utf8")})`, ); } + // A JavaScript Error subclass's property bag records, once a dynamic + // write has committed the view, where `name` and `message` were created + // (zod's error factory assigns `name` before it defines `message`); the + // slots hold their values. + if ( + meta.def.errorBagOrder && + meta.root.def.name === "%Error" && + meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) + ) { + host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr)`); + const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); + const slot = B.tmp(), + bag = B.tmp(); + B.line( + `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`, + ); + B.line(`${bag} = load ptr, ptr ${slot}`); + B.line(`call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p)`); + } // A frozen, sealed or non-extensible instance's view says so. if (withAttributes) { host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index abf03811e..471632408 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1634,6 +1634,15 @@ export class ClassDynamicDispatch { if (this.deletedNames === undefined) this.deletedNames = deletedPropertyNames(lowerer.moduleOrder); if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root); + // An Error family's bag records where `name` and `message` were + // created, which the error slots do not. + if (root.base?.def.name === "%Error") { + const order = (current: ClassInfo): void => { + current.def.errorBagOrder = true; + current.subclasses.forEach(order); + }; + order(info); + } } const loc = this.propertyBag.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 550333edd..534911042 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1499,6 +1499,10 @@ export interface IrClassDef { * class of the chain sets `name` during construction, and then an own * `name` follows every other own property. */ errorNameAfter?: string | null; + /** JavaScript Error subclasses with a property bag: a view committed by a + * dynamic write keeps `name` and `message` where the bag recorded them + * (scr_error_dyn_follow_bag). */ + errorBagOrder?: boolean; /** Stable module symbol identities for public instance layout fields. */ symbolFields?: { field: string; globalId: string }[]; /** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json new file mode 100644 index 000000000..4b64d1d75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8112-error-factory-own-key-order.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json new file mode 100644 index 000000000..2310d6e0f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json @@ -0,0 +1,61 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1398, + "end": 1407 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1413, + "end": 1422 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 3716, + "end": 3725 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 4798, + "end": 4806 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 549, + "end": 557 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 5572, + "end": 5580 + } + } + ] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b1d052356..b743aaf0f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1330,6 +1330,10 @@ ScrDyn *scr_dyn_from_error(const ScrError *e); void scr_error_register_dyn(const ScrVt *vt, ScrDyn *(*box)(void *)); ScrDyn *scr_error_dyn_fields(const ScrError *e); void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len); +/* An Error subclass view of an instance whose class keeps a property bag: + * `name` and `message` take their committed positions, and data values + * from the slots (scr_json.c). */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e); /* An accessor property of a fresh object: enumerable and configurable, * with the given getter and setter (either may be NULL). Moves both. */ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *getter, ScrDyn *setter); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 35e1d7674..2086ab5ee 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8310,6 +8310,63 @@ void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len) view->v.obj.index = NULL; } +static size_t scr_dyn_entry_index(const ScrDyn *object, const char *key, size_t key_len) { + for (size_t i = 0; i < object->v.obj.len; i++) { + const ScrDynEntry *entry = &object->v.obj.entries[i]; + if (entry->key_len == key_len && memcmp(entry->key, key, key_len) == 0) return i; + } + return SIZE_MAX; +} + +/* The bag holds the view as it was last committed, in the order its + * properties were created: zod's error factory assigns `name` and then + * defines `message`, and Node lists them in that order. The slots rebuild + * both at fixed positions (scr_error_dyn_fields, scr_error_dyn_place_name), + * so each one present in the bag moves to follow the property it follows + * there. A typed write to `name` or `message` reaches only the slot, so a + * data property takes the slot's value. */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e) { + if (!bag || bag->kind != SCR_DYN_OBJ || view->kind != SCR_DYN_OBJ) return; + static const struct { const char *key; size_t len; } slots[] = {{"name", 4}, {"message", 7}}; + for (size_t s = 0; s < 2; s++) { + bool name = s == 0; + if (!(name ? e->name_present : e->message_present)) continue; + size_t at = scr_dyn_entry_index(view, slots[s].key, slots[s].len); + if (at == SIZE_MAX || view->v.obj.entries[at].accessor) continue; + ScrDyn *value = scr_dyn_new_str(name ? e->name : e->message); + scr_dyn_region_store(view, value); + ScrDyn *old = view->v.obj.entries[at].value; + view->v.obj.entries[at].value = value; + scr_dyn_release(old); + } + bool moved = false; + for (size_t b = 0; b < bag->v.obj.len; b++) { + const ScrDynEntry *bag_entry = &bag->v.obj.entries[b]; + bool slot = (bag_entry->key_len == 4 && memcmp(bag_entry->key, "name", 4) == 0) || + (bag_entry->key_len == 7 && memcmp(bag_entry->key, "message", 7) == 0); + if (!slot) continue; + size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); + if (from == SIZE_MAX) continue; + /* The nearest earlier bag property the view has; none puts it first. */ + size_t after = SIZE_MAX; + for (size_t p = b; p-- > 0 && after == SIZE_MAX;) + after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + size_t to = after == SIZE_MAX ? 0 : after < from ? after + 1 : after; + if (to == from) continue; + ScrDynEntry *entries = view->v.obj.entries; + ScrDynEntry entry = entries[from]; + if (to < from) memmove(&entries[to + 1], &entries[to], (from - to) * sizeof *entries); + else memmove(&entries[from], &entries[from + 1], (to - from) * sizeof *entries); + entries[to] = entry; + moved = true; + } + if (moved) { + SCR_JS_SHAPE_RESET(view); + free(view->v.obj.index); + view->v.obj.index = NULL; + } +} + ScrDyn *scr_dyn_from_error(const ScrError *e) { size_t hit = scr_errdyn_find(e, true); if (hit != SIZE_MAX) return scr_dyn_retain(scr_errdyn_cache[hit].dyn); diff --git a/tests/corpus/8112-error-factory-own-key-order.js b/tests/corpus/8112-error-factory-own-key-order.js new file mode 100644 index 000000000..3e7cf277a --- /dev/null +++ b/tests/corpus/8112-error-factory-own-key-order.js @@ -0,0 +1,73 @@ +"use strict"; +// Errors built by a constructor factory, the way zod 4 builds ZodError: a +// class extending the parent passed in, then an initializer that assigns +// `name`, defines non-enumerable fields and an enumerable `message` +// accessor, and a second initializer that reassigns `name`. JSON and +// Object.keys list `name` before `message`, as they were created, and +// `name` holds the last assignment. + +const messageDescriptor = { + get() { + return this.state.message ?? JSON.stringify(this.state.issues); + }, + set(value) { + this.state.message = value; + }, + enumerable: true, + configurable: true, +}; + +function factory(name, initializer, parent) { + const Parent = parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function construct(def) { + const instance = parent ? new Definition() : this; + Object.defineProperty(instance, "state", { value: { issues: def }, enumerable: false }); + initializer(instance, def); + return instance; + } + Object.defineProperty(construct, "init", { value: initializer }); + Object.defineProperty(construct, "name", { value: name }); + return construct; +} + +const baseInit = (instance, def) => { + instance.name = "$BaseError"; + Object.defineProperty(instance, "issues", { value: def, enumerable: false }); + Object.defineProperty(instance, "message", messageDescriptor); +}; +const BaseError = factory("$BaseError", baseInit, Error); +const ReportError = factory( + "ReportError", + (instance, def) => { + BaseError.init(instance, def); + instance.name = "ReportError"; + }, + Error, +); + +const report = new ReportError([{ code: "too_small", path: ["title"] }]); +console.log(report.name, report instanceof Error, Object.keys(report).join(",")); +console.log(JSON.stringify(report)); +console.log(JSON.stringify({ success: false, error: report })); +report.message = "replaced"; +console.log(JSON.stringify(report), report.message); +report.name = "Renamed"; +console.log(JSON.stringify(report), report.name); + +const base = new BaseError([1, 2]); +console.log(base.name, Object.keys(base).join(","), JSON.stringify(base)); + +// The same steps on a class extending Error directly. +function build(flag) { + class Direct extends Error {} + const instance = flag ? new Direct() : {}; + instance.name = "First"; + Object.defineProperty(instance, "message", messageDescriptor); + Object.defineProperty(instance, "state", { value: { issues: ["i"] }, enumerable: false }); + instance.name = "Second"; + return instance; +} +const direct = build(true); +console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts new file mode 100644 index 000000000..9981e7154 --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts @@ -0,0 +1,38 @@ +// @hono/zod-validator without a hook: a failed check answers 400 with the +// whole safeParse result serialized, so the ZodError's own enumerable +// properties (name, then message) and their order reach the client. +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +const Item = z.object({ title: z.string().min(2), qty: z.number().int().positive() }); + +const app = new Hono(); +app.post("/items", zValidator("json", Item), (c) => { + const item = c.req.valid("json"); + return c.json({ created: item.title, qty: item.qty }, 201); +}); +app.get("/items", zValidator("query", z.object({ limit: z.coerce.number().int().max(5) })), (c) => { + return c.json({ limit: c.req.valid("query").limit }); +}); + +const post = (body: unknown) => + app.request("/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + +const show = async (label: string, res: Response | Promise) => { + const r = await res; + console.log(label, r.status, await r.text()); +}; + +await show("ok", post({ title: "pen", qty: 2 })); +await show("bad-title", post({ title: "p", qty: 2 })); +await show("bad-both", post({ title: 3, qty: -1 })); +await show("query-ok", app.request("/items?limit=4")); +await show("query-bad", app.request("/items?limit=9")); + +const result = Item.safeParse({ title: "x", qty: 0 }); +if (!result.success) { + const e = result.error; + console.log(e.name, e instanceof Error, Object.keys(e).join(",")); + console.log(JSON.stringify(e)); +} diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/package.json b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json new file mode 100644 index 000000000..a734aaddb --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json @@ -0,0 +1 @@ +{ "name": "hono-zod-default-hook-case", "private": true, "type": "module" } diff --git a/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json new file mode 100644 index 000000000..9013dc548 --- /dev/null +++ b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 10300 } From e81c9ddaa4f61409c88d1cdb58ef53e473bd3da5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:42:19 -0700 Subject: [PATCH 05/11] Give the hono-zod-default-hook npm case the zod 4 cases' timeout Co-Authored-By: Claude Opus 5.5 --- tests/harness/npm.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index ff9e09d61..2333f13da 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -114,10 +114,12 @@ const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); /** Per-case timeouts past the default 120 s: the undici cases compile the * built-in undici implementation's module graph, the zod 3 cases (zod3, * zod3-adoption, zod3-boundary, zod3-infer) compile zod 3's 3,700-line - * types module, and the zod 4 cases (zod4-boundary, hono-zod-validator) + * types module, and the zod 4 cases (zod4-boundary, zod4-record, + * hono-zod-validator, hono-zod-default-hook) * compile zod 4's core, classic API and locales. A sanitized build of any * of them takes minutes on a CI runner. */ const TIMEOUTS: Record = { + "hono-zod-default-hook": 900_000, "hono-zod-validator": 900_000, undici5: 600_000, undici7: 600_000, From a4e7a03761ce4a842fb424159d4ad133f618a922 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 19:37:04 -0500 Subject: [PATCH 06/11] Address review comments --- ...8113-error-class-field-before-name.js.json | 6 +++ packages/runtime/src/scr_json.c | 42 +++++++++++++-- .../8113-error-class-field-before-name.js | 53 +++++++++++++++++++ ...8113-error-class-field-before-name.js.json | 1 + 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json create mode 100644 tests/corpus/8113-error-class-field-before-name.js create mode 100644 tests/harness/corpus-costs/8113-error-class-field-before-name.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json new file mode 100644 index 000000000..ddf5981f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8113-error-class-field-before-name.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2086ab5ee..49db3cf3e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8318,6 +8318,42 @@ static size_t scr_dyn_entry_index(const ScrDyn *object, const char *key, size_t return SIZE_MAX; } +/* Whether bag entry b is one of the entries Error's constructor creates + * before the instance's class fields: `stack`, a hidden entry (a key + * starting with '%'), and a `message` that directly follows them. */ +static bool scr_error_bag_constructed(const ScrDyn *bag, size_t b) { + for (size_t i = 0; i <= b; i++) { + const ScrDynEntry *entry = &bag->v.obj.entries[i]; + bool constructed = (entry->key_len == 5 && memcmp(entry->key, "stack", 5) == 0) || + (entry->key_len > 0 && entry->key[0] == '%') || + (entry->key_len == 7 && memcmp(entry->key, "message", 7) == 0); + if (!constructed) return false; + } + return true; +} + +/* The view index at which bag entry b (`name` when `name`, else `message`), + * now at `from`, belongs: after the nearest earlier bag property the view + * has. `message` without one goes first. `name` passes over the entries + * Error's constructor creates: the view's class fields, which live in slots + * and not in the bag, follow those and come before an own `name`. Without + * another earlier property, `name` goes before the nearest later one the + * view has, and without either it stays where the view put it. */ +static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t b, bool name, + size_t from) { + for (size_t p = b; p-- > 0;) { + if (name && scr_error_bag_constructed(bag, p)) break; + size_t after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + if (after != SIZE_MAX) return after < from ? after + 1 : after; + } + if (!name) return 0; + for (size_t p = b + 1; p < bag->v.obj.len; p++) { + size_t before = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + if (before != SIZE_MAX) return before > from ? before - 1 : before; + } + return from; +} + /* The bag holds the view as it was last committed, in the order its * properties were created: zod's error factory assigns `name` and then * defines `message`, and Node lists them in that order. The slots rebuild @@ -8347,11 +8383,7 @@ void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e if (!slot) continue; size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); if (from == SIZE_MAX) continue; - /* The nearest earlier bag property the view has; none puts it first. */ - size_t after = SIZE_MAX; - for (size_t p = b; p-- > 0 && after == SIZE_MAX;) - after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); - size_t to = after == SIZE_MAX ? 0 : after < from ? after + 1 : after; + size_t to = scr_error_bag_place(view, bag, b, bag_entry->key_len == 4, from); if (to == from) continue; ScrDynEntry *entries = view->v.obj.entries; ScrDynEntry entry = entries[from]; diff --git a/tests/corpus/8113-error-class-field-before-name.js b/tests/corpus/8113-error-class-field-before-name.js new file mode 100644 index 000000000..8b40d06da --- /dev/null +++ b/tests/corpus/8113-error-class-field-before-name.js @@ -0,0 +1,53 @@ +"use strict"; +// Error subclasses with a class field, which their instances create before +// an own `name`, and a property bag that a later dynamic definition or +// write commits. Object.keys and JSON list the field before `name`, and a +// `message` that Error's constructor created before both. + +const messageDescriptor = { + get() { + return "described"; + }, + enumerable: true, + configurable: true, +}; + +// A factory-built class: the field, then an initializer that assigns `name` +// and defines `message`. +function factory(initializer) { + class Definition extends Error { + code = "E_FIELD"; + } + return function construct() { + const instance = new Definition(); + initializer(instance); + return instance; + }; +} +const Fielded = factory((instance) => { + instance.name = "Fielded"; + Object.defineProperty(instance, "message", messageDescriptor); +}); +const fielded = new Fielded(); +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded)); +fielded.extra = 1; +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded)); +fielded.name = "Renamed"; +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded), fielded.name); + +// A class whose constructor passes a message to Error and then assigns +// `name`; the message is redefined after construction. +class Direct extends Error { + code = "E_DIRECT"; + constructor() { + super("constructed"); + this.name = "Direct"; + } +} +function redefine(instance) { + Object.defineProperty(instance, "message", messageDescriptor); + instance.extra = 2; + return instance; +} +const direct = redefine(new Direct()); +console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); diff --git a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json new file mode 100644 index 000000000..66dce6341 --- /dev/null +++ b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 8600 } From c9f1f95d5b466913b6985f813b351227cbb554e1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 15:18:50 -0700 Subject: [PATCH 07/11] Box JavaScript class instances at their exact class A class instance converted to an untyped value was boxed in a capsule of its static class. Before any member table read it, %dyn.class.normalize tested it against each subclass and boxed it again at the instance's own class: two capsules per crossing for every value of a class with subclasses, and the subclass tests on every member read. After the dispatch tables settle, each conversion of a value whose static JavaScript class has a normalization branch now calls %dyn.class.boxExact:, which runs the same subclass tests in the same order once, at the conversion. The capsule is created at the exact class, and normalization falls through for it. TypeScript hierarchies, classes that are never boxed, and hierarchies without subclasses are untouched. zod 3, z.array(Order).parse over 500 orders: 178.5M -> 174.4M instructions per round (-2.3%). Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 99 ++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 7 +- .../compiler/test/js-class-layouts.test.ts | 18 +++ .../7796-boxed-subclass-identity.js.json | 6 + tests/corpus/7796-boxed-subclass-identity.js | 106 ++++++++++++++++++ .../7796-boxed-subclass-identity.js.json | 1 + 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json create mode 100644 tests/corpus/7796-boxed-subclass-identity.js create mode 100644 tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index abf03811e..701d08e4a 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -717,6 +717,105 @@ export function classForInHasKey( return { kind: "call", callee: name, args: [value, key], type: BOOL, loc }; } +/** Box class instances at their exact class. `%dyn.class.normalize` turns a + * capsule of a class with subclasses into a capsule of the instance's own + * class before any member table reads it, so every such box was followed + * by a second capsule. Each conversion of a value whose static class has a + * normalization branch now selects the exact class at the conversion, with + * the same tests in the same order (%dyn.class.boxExact:C), and the + * normalization of the resulting capsule falls through. Only JavaScript + * class hierarchies, whose instances live in untyped code, take the exact + * box. */ +export function boxClassInstancesExactly(lowerer: Lowerer, functions: IrFunction[]): void { + const normalize = lowerer.liftedFnNamed("%dyn.class.normalize"); + if (!normalize) return; + // Static class → its constructible descendants in normalization order. + const branches = new Map(); + for (const stmt of normalize.body) { + if (stmt.kind !== "if" || stmt.cond.kind !== "libCall" || stmt.cond.fn !== "dyn.typedRefIs") + continue; + const key = stmt.cond.args[1]; + if (key?.kind !== "strLit" || !key.value.startsWith("object:")) continue; + const children: string[] = []; + for (const inner of stmt.then) { + if (inner.kind !== "if" || inner.cond.kind !== "instanceOf") continue; + children.push(inner.cond.className); + } + // TypeScript hierarchies keep their static-class capsules: their boxes + // are rarer, and an exact box would emit each subclass's checked view. + const className = key.value.slice("object:".length); + const decl = lowerer.classes.get(className)?.decl; + if (!decl || !isJsSourceFile(ts.getSourceFileOfNode(decl))) continue; + if (children.length) branches.set(className, children); + } + if (branches.size === 0) return; + const loc = normalize.loc; + const helpers = new Map(); + const helperFor = (className: string): string => { + const known = helpers.get(className); + if (known !== undefined) return known; + const name = `%dyn.class.boxExact:${className}`; + helpers.set(className, name); + const type: IrType = { kind: "object", className }; + const value = varRef("value", type, loc); + functions.push({ + name, + params: [{ localId: "value", name: "value", type }], + returnType: DYN, + locals: [{ id: "value", name: "value", type, mutable: false }], + body: [ + ...branches.get(className)!.map((child): IrStmt => ({ + kind: "if", + cond: { kind: "instanceOf", value, className: child, type: BOOL, loc }, + then: [ + { + kind: "return", + value: { + kind: "dynFrom", + value: { + kind: "downcast", + value, + type: { kind: "object", className: child }, + loc, + }, + type: DYN, + loc, + }, + loc, + }, + ], + else_: null, + loc, + })), + { kind: "return", value: { kind: "dynFrom", value, type: DYN, loc }, loc }, + ], + loc, + }); + return name; + }; + const rewrite = (fn: IrFunction): void => { + fn.body = transformStmtList(fn.body, { + stmt: (statement) => statement, + expr: (expression) => + expression.kind === "dynFrom" && + expression.value.type.kind === "object" && + branches.has(expression.value.type.className) + ? { + kind: "call", + callee: helperFor(expression.value.type.className), + args: [expression.value], + type: DYN, + loc: expression.loc, + } + : expression, + }); + }; + for (const fn of [...functions]) { + if (fn === normalize || fn.name.startsWith("%dyn.class.boxExact:")) continue; + rewrite(fn); + } +} + /** Record field creation after the dispatch worklist closes, including writes * in generated member tables. Native values stay in their slots; the bag * owns only their property presence, attributes, and insertion position. */ diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 672a995fc..13ab793fb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -149,7 +149,11 @@ import type { FrontendServices } from "../services.js"; import { InternalCompilerError } from "../../errors.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js"; import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; -import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; +import { + ClassDynamicDispatch, + boxClassInstancesExactly, + trackClassFieldCreation, +} from "./class-dynamic-dispatch.js"; import { guardClassAttributeWrites } from "./class-attributes.js"; import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js"; import { applyHandleProperties } from "./handle-properties.js"; @@ -7731,6 +7735,7 @@ export class Lowerer { finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); + boxClassInstancesExactly(this, functions); guardClassAttributeWrites(this, functions); guardFrozenWrites(this, functions); applyHandleProperties(this, functions); diff --git a/packages/compiler/test/js-class-layouts.test.ts b/packages/compiler/test/js-class-layouts.test.ts index 110d007ee..987703110 100644 --- a/packages/compiler/test/js-class-layouts.test.ts +++ b/packages/compiler/test/js-class-layouts.test.ts @@ -221,3 +221,21 @@ test("a JavaScript file TypeScript checks uses its JSDoc types for class layouts expect(layout(unchecked, "Account").fields).toBe("owner:dyn balance:dyn"); expect(params(unchecked, "%Account.constructor")).toBe("(object, dyn, dyn): void"); }); + +test("instances of a class with subclasses box at their exact class", async () => { + const module = await lower(`${SHAPES} +/** @param {any} value */ +function keep(value) { + return value; +} +/** @param {Shape} shape */ +function cross(shape) { + return keep(shape); +} +console.log(cross(new Square()).size, cross(new Shape()).label); +`); + const helper = module.functions.find((fn) => fn.name === "%dyn.class.boxExact:Shape"); + expect(helper).toBeDefined(); + expect(calls(module, "cross")).toContain("%dyn.class.boxExact:Shape"); + expect(JSON.stringify(helper!.body)).toContain('"className":"Square"'); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json new file mode 100644 index 000000000..37f785cc2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7796-boxed-subclass-identity.js" + ], + "diags": [] +} diff --git a/tests/corpus/7796-boxed-subclass-identity.js b/tests/corpus/7796-boxed-subclass-identity.js new file mode 100644 index 000000000..12ed8b3c5 --- /dev/null +++ b/tests/corpus/7796-boxed-subclass-identity.js @@ -0,0 +1,106 @@ +"use strict"; +// Class instances of a hierarchy cross into untyped code through their +// base type many times. Each crossing names the same object: identity, +// Map, Set and WeakMap keys, properties added through untyped code, and +// field writes from either side stay one object, and every subclass keeps +// its own members, as in Node. + +class Shape { + constructor(name) { + this.name = name; + this.hits = 0; + } + area() { + return 0; + } + describe() { + return `${this.name}:${this.area()}`; + } +} + +class Square extends Shape { + constructor(side) { + super("square"); + this.side = side; + } + area() { + return this.side * this.side; + } +} + +class Cube extends Square { + constructor(side) { + super(side); + this.name = "cube"; + } + area() { + return 6 * super.area(); + } + get volume() { + return this.side ** 3; + } +} + +class Circle extends Shape { + constructor(r) { + super("circle"); + this.r = r; + } + area() { + return Math.round(Math.PI * this.r * this.r); + } +} + +/** @param {any} value */ +function untyped(value) { + return value; +} +/** @param {any} value @param {string} key @param {unknown} extra */ +function tag(value, key, extra) { + value[key] = extra; + value.hits++; + return value; +} + +/** @param {Shape} shape */ +function crossAsBase(shape) { + return untyped(shape); +} + +const shapes = [new Shape("plain"), new Square(2), new Cube(3), new Circle(1)]; +const seen = new Map(); +const weak = new WeakMap(); +const set = new Set(); +for (let round = 0; round < 50; round++) { + for (const shape of shapes) { + const boxed = crossAsBase(shape); + if (round === 0) { + seen.set(boxed, shape.name); + weak.set(boxed, round); + } + set.add(boxed); + if (boxed !== shape || untyped(boxed) !== crossAsBase(shape)) console.log("identity lost", shape.name); + } +} +console.log(seen.size, set.size, shapes.map((s) => seen.get(crossAsBase(s))).join(","), shapes.every((s) => weak.has(untyped(s)))); + +for (const shape of shapes) { + const boxed = crossAsBase(shape); + tag(boxed, "color", shape.name.length); + shape.hits += 10; + console.log(boxed.describe(), boxed.area(), boxed.hits, shape.hits, boxed.color, Object.keys(boxed).join(",")); + console.log(boxed instanceof Shape, boxed instanceof Square, boxed instanceof Cube, boxed instanceof Circle, boxed.constructor.name); +} + +/** @type {Shape} */ +const asBase = shapes[2]; +const cube = crossAsBase(asBase); +console.log(cube.volume, JSON.stringify(cube), "side" in cube, "volume" in cube, Object.prototype.hasOwnProperty.call(cube, "volume")); +/** @type {any} */ (shapes[1]).side = 5; +console.log(crossAsBase(shapes[1]).area(), shapes[1].area(), untyped(shapes[1]).side); + +const many = []; +for (let i = 0; i < 1000; i++) many.push(crossAsBase(i % 2 ? new Square(i) : new Circle(i))); +let squares = 0; +for (const item of many) if (item instanceof Square) squares += item.side; +console.log(many.length, squares, many[999].describe(), many[998].describe()); diff --git a/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json b/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json new file mode 100644 index 000000000..3dfdb2b6d --- /dev/null +++ b/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json @@ -0,0 +1 @@ +{ "plain": 4600, "san": 5500 } From 9ab731bf668005f196fe5d0020cedd08629e686d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 19:42:52 -0500 Subject: [PATCH 08/11] Version while loops only for accesses at their counters A while loop over typed arrays also versioned when its only checks were thread-owned receivers or static indices. A worker's job loop such as `while ((job = Atomics.load(next, 0)) < count) { ...; run(job) }` then got a second copy whose guard fails whenever the array is shared, and the doubled call kept the job function from being inlined. A while loop now versions only when it has an access at one of its counters. Other while loops compile as they did before they could version. --- .../src/backend/llvm/bytes-loop-versions.test.ts | 12 ++++++++++-- .../compiler/src/backend/llvm/bytes-loop-versions.ts | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index de71a6ef0..aef39ef75 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -385,7 +385,10 @@ test("while loop counters below a bound version their accesses", () => { ]); const declared = (loop: IrStmt): IrFunction => fn([{ kind: "varDecl", localId: "i", init: ref("k"), loc }, loop]); - const versionOf = (loop: IrStmt & { kind: "while" }): BytesLoopVersion | null => { + const versionOf = ( + loop: IrStmt & { kind: "while" }, + workers = false, + ): BytesLoopVersion | null => { const f = declared(loop); const ranges = analyzeIntegerRanges(f); return matchBytesLoopVersion(loop, null, { @@ -396,7 +399,7 @@ test("while loop counters below a bound version their accesses", () => { initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(), proven: new Set(), scalarStrings: new Set(), - workers: false, + workers, owned: new Set(), }); }; @@ -413,6 +416,11 @@ test("while loop counters below a bound version their accesses", () => { // An access after the counter advances in the same pass stays checked. const advanced = whileLoop([step, set(ref("out", f64s), ref("i"), num(0))]); expect(versionOf(advanced)).toBeNull(); + // In a worker program, a while loop versions for its counter's accesses, + // and not for thread-owned receivers alone. + expect(versionOf(copy, true)!.ownedReceivers).toHaveLength(2); + const job = whileLoop([set(ref("out", f64s), ref("k"), get(ref("src", f64s), ref("k"))), step]); + expect(versionOf(job, true)).toBeNull(); const mod: IrModule = { irVersion: 17, sourceFile: loc.file, diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 2013d03e4..1b51deee1 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -581,6 +581,11 @@ export function matchBytesLoopVersion( pairedKept.length === 0 ) return null; + // A while loop versions only for accesses at its counters. Its other + // checks (thread-owned receivers, static indices) save a test or two per + // pass, while a second copy of its body doubles the calls it makes, which + // can keep a worker's job loop from inlining the function that runs a job. + if (loop.kind === "while" && boundedKept.length === 0) return null; const bounds = new Set(); const ownedNodes = new Set(); const used = new Set(); From e813250813691b14036442c7ac42c04fffdc0f8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:22:14 -0500 Subject: [PATCH 09/11] Keep a constructor's name ahead of later properties in the runtime fallback --- .../src/backend/llvm/expr-stream-bridges.ts | 9 +++-- packages/runtime/src/scr_dyn.h | 5 +-- packages/runtime/src/scr_json.c | 35 +++++++++++-------- .../8113-error-class-field-before-name.js | 33 ++++++++++++++--- ...8113-error-class-field-before-name.js.json | 2 +- 5 files changed, 60 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index d6d5a5b7c..46c2d884f 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -996,13 +996,14 @@ export function streamTypedRefMaterializeAdapter( // A JavaScript Error subclass's property bag records, once a dynamic // write has committed the view, where `name` and `message` were created // (zod's error factory assigns `name` before it defines `message`); the - // slots hold their values. + // slots hold their values. A `name` the class's construction sets is + // already where errorNameAfter places it among the fields. if ( meta.def.errorBagOrder && meta.root.def.name === "%Error" && meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) ) { - host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr)`); + host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr, i1)`); const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); const slot = B.tmp(), bag = B.tmp(); @@ -1010,7 +1011,9 @@ export function streamTypedRefMaterializeAdapter( `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`, ); B.line(`${bag} = load ptr, ptr ${slot}`); - B.line(`call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p)`); + B.line( + `call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p, i1 ${nameAfter === undefined ? "false" : "true"})`, + ); } // A frozen, sealed or non-extensible instance's view says so. if (withAttributes) { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b743aaf0f..d84bb214d 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1332,8 +1332,9 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e); void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len); /* An Error subclass view of an instance whose class keeps a property bag: * `name` and `message` take their committed positions, and data values - * from the slots (scr_json.c). */ -void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e); + * from the slots (scr_json.c). `name_placed`: the class's construction sets + * `name`, and the view already has it in place. */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e, bool name_placed); /* An accessor property of a fresh object: enumerable and configurable, * with the given getter and setter (either may be NULL). Moves both. */ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *getter, ScrDyn *setter); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 49db3cf3e..d583512ca 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8332,21 +8332,26 @@ static bool scr_error_bag_constructed(const ScrDyn *bag, size_t b) { return true; } -/* The view index at which bag entry b (`name` when `name`, else `message`), - * now at `from`, belongs: after the nearest earlier bag property the view - * has. `message` without one goes first. `name` passes over the entries - * Error's constructor creates: the view's class fields, which live in slots - * and not in the bag, follow those and come before an own `name`. Without - * another earlier property, `name` goes before the nearest later one the - * view has, and without either it stays where the view put it. */ +/* The view index at which bag entry b, now at `from`, belongs: after the + * nearest earlier bag property the view has. An entry created after + * construction (any `name` placed here, since no constructor of the class + * sets it, and a `message` that is not one of Error's constructor entries) + * also follows the class fields, which live in slots and not in the bag: it + * passes over the constructor's entries and over a `name` the construction + * set, and without another earlier property goes before the nearest later + * one the view has, or stays where the view put it. A `message` Error's + * constructor created follows the nearest earlier entry, or goes first. */ static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t b, bool name, - size_t from) { + bool name_placed, size_t from) { + bool late = name || !scr_error_bag_constructed(bag, b); for (size_t p = b; p-- > 0;) { - if (name && scr_error_bag_constructed(bag, p)) break; - size_t after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + const ScrDynEntry *anchor = &bag->v.obj.entries[p]; + if (late && scr_error_bag_constructed(bag, p)) break; + if (late && name_placed && anchor->key_len == 4 && memcmp(anchor->key, "name", 4) == 0) continue; + size_t after = scr_dyn_entry_index(view, anchor->key, anchor->key_len); if (after != SIZE_MAX) return after < from ? after + 1 : after; } - if (!name) return 0; + if (!late) return 0; for (size_t p = b + 1; p < bag->v.obj.len; p++) { size_t before = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); if (before != SIZE_MAX) return before > from ? before - 1 : before; @@ -8361,7 +8366,7 @@ static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t * so each one present in the bag moves to follow the property it follows * there. A typed write to `name` or `message` reaches only the slot, so a * data property takes the slot's value. */ -void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e) { +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e, bool name_placed) { if (!bag || bag->kind != SCR_DYN_OBJ || view->kind != SCR_DYN_OBJ) return; static const struct { const char *key; size_t len; } slots[] = {{"name", 4}, {"message", 7}}; for (size_t s = 0; s < 2; s++) { @@ -8380,10 +8385,12 @@ void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e const ScrDynEntry *bag_entry = &bag->v.obj.entries[b]; bool slot = (bag_entry->key_len == 4 && memcmp(bag_entry->key, "name", 4) == 0) || (bag_entry->key_len == 7 && memcmp(bag_entry->key, "message", 7) == 0); - if (!slot) continue; + bool name = bag_entry->key_len == 4; + /* A `name` the class's construction sets is in place already. */ + if (!slot || (name && name_placed)) continue; size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); if (from == SIZE_MAX) continue; - size_t to = scr_error_bag_place(view, bag, b, bag_entry->key_len == 4, from); + size_t to = scr_error_bag_place(view, bag, b, name, name_placed, from); if (to == from) continue; ScrDynEntry *entries = view->v.obj.entries; ScrDynEntry entry = entries[from]; diff --git a/tests/corpus/8113-error-class-field-before-name.js b/tests/corpus/8113-error-class-field-before-name.js index 8b40d06da..e10a9456b 100644 --- a/tests/corpus/8113-error-class-field-before-name.js +++ b/tests/corpus/8113-error-class-field-before-name.js @@ -1,8 +1,10 @@ "use strict"; -// Error subclasses with a class field, which their instances create before -// an own `name`, and a property bag that a later dynamic definition or -// write commits. Object.keys and JSON list the field before `name`, and a -// `message` that Error's constructor created before both. +// Error subclasses with a property bag that a later dynamic definition or +// write commits. Object.keys and JSON keep the order in which an instance +// created its properties: a class field before an own `name` that is +// assigned after construction, a `name` a constructor assigns before a +// later property, and a `message` that Error's constructor created before +// all of them. const messageDescriptor = { get() { @@ -51,3 +53,26 @@ function redefine(instance) { } const direct = redefine(new Direct()); console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); + +// Constructors that assign `name` and then another property, which their +// instances create in that order, with and without a message passed to +// Error, before the message is redefined. +class Named extends Error { + constructor() { + super(); + this.name = "Named"; + this.detail = "named"; + } +} +const named = redefine(new Named()); +console.log(Object.keys(named).join(","), JSON.stringify(named), named.name); + +class NamedMessage extends Error { + constructor() { + super("constructed"); + this.name = "NamedMessage"; + this.detail = "named"; + } +} +const namedMessage = redefine(new NamedMessage()); +console.log(Object.keys(namedMessage).join(","), JSON.stringify(namedMessage), namedMessage.name); diff --git a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json index 66dce6341..29164fca3 100644 --- a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json +++ b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json @@ -1 +1 @@ -{ "plain": 1700, "san": 8600 } +{ "plain": 1900, "san": 9700 } From 0f75f5b95248b8c8fa1f501d597db279d5ead566 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:31:12 -0500 Subject: [PATCH 10/11] Keep the run-time result of overloaded calls with untyped arguments The checker resolves an overloaded call whose argument is `any` to the first overload that argument satisfies, but in JavaScript the value can select another one: generateKeyPairSync("ec", options) returns KeyObjects or PEM strings by the options, and digest(encoding) returns a Buffer when the encoding is undefined. The result was converted to the first overload's return type, so such calls threw a TypeError where Node returns the other overload's value. When a checked-dynamic argument could take another overload whose return type differs (trailing untyped or undefined arguments may also be absent), the call's result in a JavaScript source is checked-dynamic. So are the unannotated bindings it initializes, destructured or not, and the unannotated functions that return it. fs.globSync keeps choosing its result form from the resolved overload, since the runtime checks that withFileTypes agrees with it. --- docs/content/limitations/untyped-values.mdx | 3 + .../src/frontend/lowering/builtins/fs-glob.ts | 11 +- .../src/frontend/lowering/function-returns.ts | 17 ++ .../src/frontend/lowering/lower-calls.ts | 4 + .../compiler/src/frontend/lowering/lowerer.ts | 17 ++ .../src/frontend/lowering/overload-choice.ts | 197 ++++++++++++++++++ .../7453-untyped-overload-results.cjs.json | 6 + .../corpus/7453-untyped-overload-results.cjs | 85 ++++++++ .../7453-untyped-overload-results.cjs.json | 1 + 9 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/overload-choice.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json create mode 100644 tests/corpus/7453-untyped-overload-results.cjs create mode 100644 tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json diff --git a/docs/content/limitations/untyped-values.mdx b/docs/content/limitations/untyped-values.mdx index 6a881aec3..05245a396 100644 --- a/docs/content/limitations/untyped-values.mdx +++ b/docs/content/limitations/untyped-values.mdx @@ -15,3 +15,6 @@ JavaScript argument and return conversions preserve native array references; oth Supported operations on `unknown` include truthiness, `typeof` narrowing, property access, `+`, `switch`, and `throw`. Cast before using other operations. + +In JavaScript, a call whose untyped argument could select an overload with another result type returns a checked value, whichever overload the type checker picks, and so do the unannotated bindings and functions that hold or return it. +In TypeScript, an `any` argument keeps the overload the type checker picks, and a value that selects another overload at runtime throws a `TypeError` where the result is used. diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts index f988c204d..4d88cd96e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts +++ b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts @@ -22,7 +22,10 @@ import { builtinImportOf } from "./module-bindings.js"; * lib/internal/fs/glob.js). The pattern and options cross as dyn values so * the runtime validates them in Node's order. The result form (strings or * Dirent rows) comes from the call's mapped result type, so withFileTypes - * must be statically known; the runtime checks that the option agrees. */ + * must be statically known; the runtime checks that the option agrees. + * Because of that check, the form follows the checker's resolved overload + * even where an untyped options argument leaves the overload undecided + * (dynamicOverloadChoice). */ /** The interned Dirent record array (type-mapper.ts), or null. */ function direntArray(lowerer: Lowerer, type: IrType | null | undefined): boolean { @@ -67,7 +70,7 @@ export function lowerFsGlobSyncCall( loc: SrcLoc, ): IrExpr { const args = globArgs(lowerer, call, "fs.globSync"); - const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const callType = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(call)); const types = direntArray(lowerer, callType); if (types) fenceDirentExclude(lowerer, call, "fs.globSync"); if (!types && !(callType?.kind === "array" && callType.elem.kind === "string")) { @@ -132,7 +135,9 @@ export function lowerFspGlobCall(lowerer: Lowerer, call: ts.CallExpression, loc: } const args = globArgs(lowerer, call, "fs/promises glob"); // The async iterator's yielded type (its first type argument). - const yielded = lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0]; + const yielded = lowerer.checker.getTypeArguments( + lowerer.checker.getTypeAtLocation(call) as ts.TypeReference, + )[0]; const element = yielded ? lowerer.mapTypeOf(yielded) : null; const rows: IrType | null = element ? { kind: "array", elem: element } : null; const types = direntArray(lowerer, rows); diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index dde109ea7..6d0989e2b 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -17,6 +17,7 @@ import { PoisonError, dynFallbackType } from "./lowerer.js"; import { functionCanReturnUndefined } from "../function-completion.js"; import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js"; import type { FnCtx } from "./function-context.js"; +import { returnsGuessedOverloadValue } from "./overload-choice.js"; /** Keep the conservative completion rule shared by return construction * and inference. Nested control flow may still make completion unreachable. */ @@ -256,6 +257,22 @@ export function declaredReturnType( return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ? { kind: "promise", inner: DYN } : DYN; + // A JS function returning a value whose type rests on an undecided + // overload (guessedOverloadValue): the inferred result is the first + // overload's, which the returned value need not have. + if ( + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !decl.type && + !decl.asteriskToken && + !hasExplicitJsDocReturn(decl) && + mappedReturn !== null && + mappedReturn.kind !== "dyn" && + !(mappedReturn.kind === "promise" && mappedReturn.inner.kind === "dyn") && + returnsGuessedOverloadValue(lowerer, decl) + ) + return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ? { kind: "promise", inner: DYN } + : DYN; // JS inference assigns Number to arithmetic over any, although the // runtime operands can both be BigInts. Preserve that native result. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 8ac97fd32..073ebed91 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -260,6 +260,7 @@ import { completeArgs, } from "./call-arguments.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; +import { dynamicOverloadChoice } from "./overload-choice.js"; import { lowerStringIteratorValue } from "./iterator-adapters.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; @@ -5506,6 +5507,9 @@ function reconcileOverloadReturn( expr: ts.CallExpression | ts.TaggedTemplateExpression, call: IrExpr, ): IrExpr { + // The checker's overload is a guess for a checked-dynamic argument + // another overload accepts: the implementation's value stands. + if (ts.isCallExpression(expr) && dynamicOverloadChoice(lowerer, expr)) return call; const rsig = lowerer.checker.getResolvedSignature(expr); const rdecl = rsig ? rsig.declaration : undefined; // Nominal covariant overrides share the base's return ABI, but retain diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0d7b89d49..2fcfc063b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -434,6 +434,7 @@ import { collectSignatureInner, lambdaSignature, } from "./call-signatures.js"; +import { dynamicOverloadChoice, guessedOverloadIdentifier } from "./overload-choice.js"; import { implicitAnyParamSymbolsOf, implicitLocalFnNodeOf, @@ -2845,6 +2846,8 @@ export class Lowerer { /** Parameters read as `any` in the error-path copy of an error-first * callback. */ readonly anySymbols = new Set(); + /** overload-choice's answers per call, binding and function. */ + readonly dynamicOverloadAnswers = new Map(); /** Error parameters of an error-first callback with a known value in * the copy being lowered: true while it holds an Error, false while it * holds null (staticCondition folds conditions on them). */ @@ -9037,6 +9040,20 @@ export class Lowerer { typeOf(node: ts.Node): ts.Type { if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType(); + // A checked-dynamic argument that another overload could take at run + // time leaves the resolved overload's return a guess, and JS bindings + // initialized with that result hold whatever it was. + if ( + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + dynamicOverloadChoice(this, node) + ) + return this.checker.getAnyType(); + if ( + ts.isIdentifier(node) && + isJsSourceFile(ts.getSourceFileOfNode(node)) && + guessedOverloadIdentifier(this, node) + ) + return this.checker.getAnyType(); if (ts.isPropertyAccessExpression(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { const receiver = node.expression.kind === ts.SyntaxKind.ThisKeyword && this.fnStack.length > 0 diff --git a/packages/compiler/src/frontend/lowering/overload-choice.ts b/packages/compiler/src/frontend/lowering/overload-choice.ts new file mode 100644 index 000000000..f973ccf2e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/overload-choice.ts @@ -0,0 +1,197 @@ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { typeEquals, type IrType } from "../../ir/ir.js"; +import { isJsSourceFile } from "../program.js"; +import { isThisParameter } from "./call-signatures.js"; +import { hasExplicitJsDocReturn } from "./function-returns.js"; +import { hasJsTypeAnnotation } from "./lower-stmts.js"; + +/** Whether a checked-dynamic argument leaves a JS call's overload + * undecided. + * + * The checker resolves an overloaded call whose argument is `any` (an + * untyped parameter, a value read from one) to the FIRST overload that + * argument satisfies, but the value can match any overload at run time: + * `generateKeyPairSync("ec", options)` answers KeyObjects or encoded + * strings by the options' contents, `digest(encoding)` a Buffer when the + * encoding is undefined. When another overload the call could take at + * run time returns a different type, the resolved overload's return is a + * guess, and the result is checked-dynamic instead. + * + * An overload is reachable when the arguments fit its arity (trailing + * checked-dynamic or `undefined` arguments may be absent) and every + * statically typed argument is assignable to its parameter. Generic + * overloads whose return cannot be mapped without instantiation do not + * count, so the resolved instantiation stands for them. */ +export function dynamicOverloadChoice( + lowerer: Lowerer, + expr: ts.CallExpression | ts.NewExpression, +): boolean { + return cachedAnswer(lowerer, expr, () => computeDynamicOverloadChoice(lowerer, expr)); +} + +/** Whether a JavaScript value's checker type rests on an undecided + * overload (dynamicOverloadChoice): the call itself, an awaited one, a + * call to an unannotated JS function returning such a value, or a read of + * an unannotated JS binding initialized with one (destructured or not). + * Such a value is checked-dynamic wherever the checker's inference would + * otherwise carry the guess into a binding or a return. */ +export function guessedOverloadValue(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)) expr = expr.expression; + if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) { + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + if (dynamicOverloadChoice(lowerer, expr)) return true; + const decl = lowerer.checker.getResolvedSignature(expr)?.declaration; + return ( + decl !== undefined && + (ts.isFunctionDeclaration(decl) || + ts.isFunctionExpression(decl) || + ts.isArrowFunction(decl)) && + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + returnsGuessedOverloadValue(lowerer, decl) + ); + } + return ts.isIdentifier(expr) && guessedOverloadIdentifier(lowerer, expr); +} + +/** Whether a JS identifier, as a binding's declared name or as a read, + * names a guessedOverloadBinding. */ +export function guessedOverloadIdentifier(lowerer: Lowerer, node: ts.Identifier): boolean { + return cachedAnswer(lowerer, node, () => { + const parent = node.parent; + if ( + parent !== undefined && + (ts.isVariableDeclaration(parent) || ts.isBindingElement(parent)) && + parent.name === node + ) + return guessedOverloadBinding(lowerer, parent); + const decl = lowerer.checker.getSymbolAtLocation(node)?.valueDeclaration; + return decl !== undefined && guessedOverloadBinding(lowerer, decl); + }); +} + +/** Whether `decl` is an unannotated JS variable or destructured binding + * whose declaration's initializer is a guessedOverloadValue. */ +function guessedOverloadBinding(lowerer: Lowerer, decl: ts.Node): boolean { + let root: ts.Node | undefined = decl; + while (root && ts.isBindingElement(root) && root.parent && ts.isBindingPattern(root.parent)) { + root = root.parent.parent; + } + if (!root) return false; + if (!ts.isVariableDeclaration(root) || !root.initializer || root.type) return false; + if (!isJsSourceFile(ts.getSourceFileOfNode(root)) || hasJsTypeAnnotation(root)) return false; + const init = root.initializer; + return cachedAnswer(lowerer, root, () => guessedOverloadValue(lowerer, init)); +} + +/** Whether a return of the unannotated JS function `decl` itself is a + * guessedOverloadValue. */ +export function returnsGuessedOverloadValue( + lowerer: Lowerer, + decl: ts.SignatureDeclaration, +): boolean { + if (decl.type || decl.asteriskToken || hasExplicitJsDocReturn(decl)) return false; + return cachedAnswer(lowerer, decl, () => { + if (ts.isArrowFunction(decl) && decl.body && !ts.isBlock(decl.body)) { + return guessedOverloadValue(lowerer, decl.body); + } + let found = false; + const scan = (node: ts.Node): void => { + if (found || (node !== decl && ts.isFunctionLike(node))) return; + if (ts.isReturnStatement(node) && node.expression) { + found = guessedOverloadValue(lowerer, node.expression); + } + ts.forEachChild(node, scan); + }; + scan(decl); + return found; + }); +} + +/** One answer per node; a node asked again while its answer is being + * computed (recursive functions, self-referencing bindings) answers + * false. */ +function cachedAnswer(lowerer: Lowerer, node: ts.Node, compute: () => boolean): boolean { + const cached = lowerer.dynamicOverloadAnswers.get(node); + if (cached !== undefined) return cached; + lowerer.dynamicOverloadAnswers.set(node, false); + const answer = compute(); + lowerer.dynamicOverloadAnswers.set(node, answer); + return answer; +} + +function computeDynamicOverloadChoice( + lowerer: Lowerer, + expr: ts.CallExpression | ts.NewExpression, +): boolean { + // TypeScript sources keep the resolved overload: their reads validate + // the value against it where it is used. + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + const args = expr.arguments?.nodes ?? []; + if (args.length === 0 || args.some(ts.isSpreadElement)) return false; + const dynamic = args.map((arg) => checkedDynamicArgument(lowerer, arg)); + if (!dynamic.includes(true)) return false; + const checker = lowerer.checker; + const calleeType = checker.getTypeAtLocation(expr.expression); + const signatures = ts.isNewExpression(expr) + ? checker.getConstructSignatures(calleeType) + : checker.getCallSignatures(calleeType); + if (signatures.length < 2) return false; + const resolved = checker.getResolvedSignature(expr); + if (!resolved) return false; + const resolvedReturn = lowerer.mapTypeOf(checker.getReturnTypeOfSignature(resolved)); + if (resolvedReturn === null) return false; + // Arguments that may be absent at run time: the trailing run of + // checked-dynamic and `undefined` arguments. + let present = args.length; + while (present > 0 && (dynamic[present - 1] || isUndefinedArgument(args[present - 1]!))) { + present--; + } + for (const signature of signatures) { + const decl = signature.declaration; + if (!decl || decl === resolved.declaration || !ts.isFunctionLike(decl)) continue; + const generic = signature.getTypeParameters().length > 0; + const params = decl.parameters.nodes.filter((param) => !isThisParameter(param)); + const required = params.filter( + (param) => !param.questionToken && !param.initializer && !param.dotDotDotToken, + ).length; + const maximum = params.some((param) => param.dotDotDotToken) ? Infinity : params.length; + if (required > args.length || present > maximum) continue; + const symbols = signature.getParameters(); + let fits = true; + for (let i = 0; i < args.length && i < params.length && fits; i++) { + if (dynamic[i] || generic || params[i]!.dotDotDotToken) continue; + const symbol = symbols[i]; + if (!symbol) continue; + fits = checker.isTypeAssignableTo( + checker.getTypeAtLocation(args[i]!), + checker.getTypeOfSymbol(symbol), + ); + } + if (!fits) continue; + const candidate = lowerer.mapTypeOf(checker.getReturnTypeOfSignature(signature)); + if (candidate !== null && !holdsResult(resolvedReturn, candidate)) return true; + } + return false; +} + +/** Whether a value of the resolved overload's mapped return also holds + * every value of `candidate`: the same type, or a checked-dynamic one (a + * promise of one fulfills with anything). */ +function holdsResult(resolved: IrType, candidate: IrType): boolean { + if (resolved.kind === "dyn" || typeEquals(resolved, candidate)) return true; + return ( + resolved.kind === "promise" && + candidate.kind === "promise" && + holdsResult(resolved.inner, candidate.inner) + ); +} + +function checkedDynamicArgument(lowerer: Lowerer, arg: ts.Expression): boolean { + if (lowerer.checker.getTypeAtLocation(arg).flags & ts.TypeFlags.Any) return true; + return (lowerer.typeOf(arg).flags & ts.TypeFlags.Any) !== 0; +} + +function isUndefinedArgument(arg: ts.Expression): boolean { + return (ts.isIdentifier(arg) && arg.text === "undefined") || ts.isVoidExpression(arg); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json new file mode 100644 index 000000000..9ba935c1e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7453-untyped-overload-results.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/7453-untyped-overload-results.cjs b/tests/corpus/7453-untyped-overload-results.cjs new file mode 100644 index 000000000..21923827d --- /dev/null +++ b/tests/corpus/7453-untyped-overload-results.cjs @@ -0,0 +1,85 @@ +// An untyped argument can match any overload of a call at run time. The +// result follows the overload the value selects, through bindings, +// destructuring and function returns. +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ecPair = (options) => crypto.generateKeyPairSync("ec", options); +const objects = ecPair({ namedCurve: "P-256" }); +console.log(typeof objects.privateKey, objects.privateKey.type, objects.publicKey.asymmetricKeyType); +const pem = ecPair({ + namedCurve: "P-256", + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); +console.log(typeof pem.privateKey, pem.publicKey.split("\n")[0]); + +function privateKeyKind(options) { + const pair = crypto.generateKeyPairSync("ec", options); + return typeof pair.privateKey; +} +console.log(privateKeyKind({ namedCurve: "P-384" })); + +function spread(options) { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + ...options, + }); + return [typeof privateKey, Buffer.isBuffer(publicKey)]; +} +console.log(spread({}), spread({ publicKeyEncoding: { type: "spki", format: "der" } })); + +const digest = (encoding) => crypto.createHash("sha256").update("x").digest(encoding); +console.log(typeof digest("hex"), Buffer.isBuffer(digest(undefined)), digest(undefined).length); +const hmac = (encoding) => crypto.createHmac("sha256", "k").update("v").digest(encoding); +console.log(hmac("base64"), Buffer.isBuffer(hmac())); +function digestLength(encoding) { + let value = crypto.createHash("sha1").update("y").digest(encoding); + return Buffer.isBuffer(value) ? `buffer ${value.length}` : `string ${value.length}`; +} +console.log(digestLength(undefined), digestLength("hex")); +async function digestLater(encoding) { + return crypto.createHash("md5").update("z").digest(encoding); +} + +const key = crypto.generateKeyPairSync("ed25519").publicKey; +const exported = (options) => key.export(options); +console.log( + Buffer.isBuffer(exported({ format: "der", type: "spki" })), + typeof exported({ format: "pem", type: "spki" }), + exported({ format: "jwk" }).kty, +); + +const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "overloads-")), "text.txt"); +fs.writeFileSync(file, "hello"); +const read = (options) => fs.readFileSync(file, options); +const text = read("utf8"); +const bytes = read(undefined); +const latin1 = read({ encoding: "latin1" }); +console.log(text, Buffer.isBuffer(bytes), bytes.length, latin1); +console.log(read("utf8"), read({ encoding: "hex" })); + +/** + * @overload + * @param {string} value + * @returns {string} + */ +/** + * @overload + * @param {number} value + * @returns {number} + */ +/** @param {string | number} value */ +function same(value) { + return value; +} +const viaSame = (value) => same(value); +console.log(typeof viaSame("s"), typeof viaSame(3), viaSame(3) + 1); + +(async () => { + const later = await digestLater(undefined); + console.log(Buffer.isBuffer(later), typeof (await digestLater("hex"))); + fs.rmSync(path.dirname(file), { recursive: true }); +})(); diff --git a/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json b/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json new file mode 100644 index 000000000..46a1181bd --- /dev/null +++ b/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json @@ -0,0 +1 @@ +{ "plain": 2991, "san": 4866 } From 10e3476a55016309f78a294146f8b7cb0eebb75e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:12:15 -0500 Subject: [PATCH 11/11] Convert checked-dynamic expected errors in assert.throws and assert.match An expected regex or string read from a JavaScript object literal's field is a checked-dynamic value while its checker type is RegExp or string. assert.throws chose its regex comparison from the checker type and passed the value unconverted, an internal compiler error, and assert.match and the expected-object form refused it. Such a value now converts to the regex or string its type names. --- .../src/frontend/lowering/lower-assert.ts | 18 +++++- .../8160-assert-expected-from-fields.cjs.json | 6 ++ .../8160-assert-expected-from-fields.cjs | 58 +++++++++++++++++++ .../8160-assert-expected-from-fields.cjs.json | 1 + 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json create mode 100644 tests/corpus/8160-assert-expected-from-fields.cjs create mode 100644 tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index bc51a9382..b996abed7 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1355,7 +1355,7 @@ function lowerAssertMatch( lowerer.noLowering(`${surface} with ${expr.arguments.nodes.length} arguments`, expr); } const s = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); - const re = lowerer.lowerExpr(expr.arguments.nodes[1]!); + const re = lowerExpectedValue(lowerer, expr.arguments.nodes[1]!); if (re.type.kind !== "regex") { lowerer.noLowering( `${surface} with a non-regex pattern`, @@ -1420,6 +1420,18 @@ function inErrorHierarchy(lowerer: Lowerer, className: string): boolean { return false; } +/** An expected string or regex: a value JavaScript stores checked-dynamic + * (an object literal's field, an untyped binding) converts to the string + * or regex its checker type names, so the comparison takes that arm. */ +function lowerExpectedValue(lowerer: Lowerer, node: ts.Expression): IrExpr { + const value = lowerer.lowerExpr(node); + if (value.type.kind !== "dyn") return value; + const expected = lowerer.mapTypeOf(lowerer.typeOf(node)); + return expected?.kind === "string" || expected?.kind === "regex" + ? lowerer.coerceToExpected(value, expected) + : value; +} + /** Classify the expected argument: string → message, regex value → the * regex test over String(error), an error-class identifier → instanceof, * an object LITERAL over name/message/code (string or regex values) → @@ -1436,7 +1448,7 @@ function classifyThrowsExpected( return { form: "message", msg: m.msg, hasMsg: m.hasMsg }; } if (expectedT?.kind === "regex") { - return { form: "regex", value: lowerer.lowerExpr(node) }; + return { form: "regex", value: lowerExpectedValue(lowerer, node) }; } const sym = ts.isIdentifier(node) ? lowerer.resolveValueSymbol(node) : null; const info = @@ -1509,7 +1521,7 @@ function classifyThrowsExpected( ); } const valueNode = ts.isPropertyAssignment(named) ? named.initializer : named.name; - const value = lowerer.lowerExpr(valueNode); + const value = lowerExpectedValue(lowerer, valueNode); if (value.type.kind === "string") { byId.set(id, { id, kind: "str", value, enameBake: null }); } else if (value.type.kind === "regex") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json new file mode 100644 index 000000000..fcaacbbee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8160-assert-expected-from-fields.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/8160-assert-expected-from-fields.cjs b/tests/corpus/8160-assert-expected-from-fields.cjs new file mode 100644 index 000000000..4c16bd78f --- /dev/null +++ b/tests/corpus/8160-assert-expected-from-fields.cjs @@ -0,0 +1,58 @@ +// Expected errors stored in an object literal's fields: a JavaScript +// object keeps its fields checked-dynamic, and assert.throws, assert.match +// and the expected-object form convert them to the regex or string the +// fields hold. +"use strict"; +const assert = require("assert"); + +const errMessages = { + auth: / auth/, + state: / state/, + length: /Invalid initialization vector/, + code: "ERR_CRYPTO_INVALID_IV", +}; + +const error = (message, code) => { + const e = new Error(message); + if (code) e.code = code; + return e; +}; + +assert.throws(() => { + throw error("Unsupported state or unable to authenticate data"); +}, errMessages.auth); +assert.throws(() => { + throw error("Invalid state for operation getAuthTag"); +}, errMessages.state); +assert.throws( + () => { + throw error("Invalid initialization vector", "ERR_CRYPTO_INVALID_IV"); + }, + { code: errMessages.code, message: errMessages.length }, +); +assert.match("unable to auth", errMessages.auth); +assert.doesNotMatch("no match here", errMessages.state); + +const report = (label, run) => { + try { + run(); + console.log(label, "passed"); + } catch (error) { + console.log(label, error.name, error.code, error.message.split("\n")[0]); + } +}; +report("wrong message", () => + assert.throws(() => { + throw error("something else"); + }, errMessages.length), +); +report("wrong code", () => + assert.throws( + () => { + throw error("Invalid initialization vector", "ERR_OTHER"); + }, + { code: errMessages.code }, + ), +); +report("match", () => assert.match("plain text", errMessages.auth)); +console.log("done"); diff --git a/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json b/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json new file mode 100644 index 000000000..cdfeabea9 --- /dev/null +++ b/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json @@ -0,0 +1 @@ +{ "plain": 2656, "san": 5234 }