From ee2832afaee62a6a93d636c817830c2fdedcbfd0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 9 Oct 2026 11:18:15 -0500 Subject: [PATCH] Answer undefined for out-of-range typed array reads like Node Typed-array element reads at an index that is not an integer in [0, length) now yield undefined instead of throwing a RangeError. The read is `number | undefined`, like ordinary array reads; numeric and truthiness consumers fuse back to the plain element read, which answers NaN for an invalid index without touching storage. The may-throw seed for typed reads is removed. Invalid writes stay ignored. The corpus pins reads in printing, comparison, arithmetic, defaulting, truthiness, bindings and templates, plus ignored writes, and a received worker message is checked to arrive unfrozen. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 4 +- packages/compiler/src/backend/llvm/emitter.ts | 3 +- .../compiler/src/backend/llvm/expr-bytes.ts | 62 ++++++--- packages/compiler/src/backend/may-throw.ts | 3 +- .../src/frontend/lowering/array-values.ts | 121 ++++++++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 34 +++-- .../compiler/src/frontend/lowering/lowerer.ts | 3 +- packages/compiler/src/ir/integer-ranges.ts | 2 +- packages/compiler/src/ir/ir.ts | 7 +- packages/runtime/src/scr_bytes.c | 27 ++-- packages/runtime/src/scr_runtime.h | 1 + packages/runtime/test/bytes.test.ts | 17 ++- packages/runtime/test/test_bytes.c | 13 +- tests/corpus/repeat-and-typed-read-errors.ts | 33 ++++- tests/corpus/worker-class-instance-clone.ts | 11 +- tests/harness/errors.test.ts | 19 --- 16 files changed, 272 insertions(+), 88 deletions(-) diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index d434d8067..7f748e5ab 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -144,11 +144,11 @@ Compiled code has runtime behavior that can differ from Node.js. Account for the ### Array bounds and runtime errors -Statically typed numeric reads from typed arrays throw a catchable `RangeError` for invalid indexes, because a typed numeric read cannot produce `undefined`. Node.js returns `undefined` for these reads. Invalid numeric indexed writes are ignored, while the assignment expression retains its original value. +Typed-array reads at an index that is not an integer in the array's range return `undefined`, as in Node.js, and invalid numeric indexed writes are ignored, while the assignment expression retains its original value. A non-null assertion on such a read (`bytes[i]!`) yields `NaN` instead of `undefined`. Ordinary arrays track holes separately from present `undefined` values. Missing reads and empty `pop()`/`shift()` return `undefined`, indexed writes can grow the array, and length growth creates holes. -Runtime traps cannot be caught. User-thrown values and supported exceptions, including JSON parse, checked-cast, filesystem, regex, invalid typed-array read, and `String.prototype.repeat` count errors, can be caught; hard traps such as out-of-range reads of statically typed ordinary arrays terminate the process. +Runtime traps cannot be caught. User-thrown values and supported exceptions, including JSON parse, checked-cast, filesystem, regex, and `String.prototype.repeat` count errors, can be caught; hard traps such as out-of-range reads of statically typed ordinary arrays terminate the process. Deep recursion that exhausts the native stack terminates the process with a signal instead of throwing Node's catchable `RangeError: Maximum call stack size exceeded`. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8df227e44..a3dbcd688 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6733,8 +6733,9 @@ export class LlEmitter { index: LlValue, expr?: IrExpr, inBounds = false, + invalidNaN = false, ): LlValue { - return emitBytesGet(this, elem, receiver, index, expr, inBounds); + return emitBytesGet(this, elem, receiver, index, expr, inBounds, invalidNaN); } emitToUint32(value: string, expr?: IrExpr, uint32?: string): string { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index d3a01d7d5..41f223055 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -108,7 +108,9 @@ export function emitBytesIndex( B.startBlock(invalid); if (skipInvalid) B.br(skipInvalid); else { - emitInvalidBytesRead(host, receiver, index); + host.declare(`declare double @scr_bytes_get(ptr, double)`); + B.line(`call double @scr_bytes_get(ptr ${receiver}, double ${index})`); + B.terminate("unreachable"); } B.startBlock(valid); if (host.sizeType === "i64") return wideIndex; @@ -142,22 +144,16 @@ export function emitBytesIndex( B.startBlock(invalid); if (skipInvalid) B.br(skipInvalid); else { - emitInvalidBytesRead(host, receiver, index); + // No caller-supplied invalid path: never touch storage. + host.declare(`declare void @llvm.trap()`); + B.line(`call void @llvm.trap()`); + B.terminate("unreachable"); } B.startBlock(valid); return idx; } -/** An invalid typed-array read: the runtime throws Node's catchable - * RangeError, and this path unwinds to the handler (it never continues). */ -function emitInvalidBytesRead(host: LlvmEmitterContext, receiver: string, index: string): void { - host.declare(`declare double @scr_bytes_get(ptr, double)`); - host.B.line(`call double @scr_bytes_get(ptr ${receiver}, double ${index})`); - host.emitPendingCheck(); - host.B.terminate("unreachable"); -} - export function emitBytesData(host: LlvmEmitterContext, receiver: string): string { const p = host.B.tmp(); const data = host.B.tmp(); @@ -192,16 +188,51 @@ export function emitBytesGet( index: LlValue, expr?: IrExpr, inBounds = false, + invalidNaN = false, ): LlValue { const B = host.B; if (host.mod.workers) { - host.declare(`declare double @scr_bytes_get(ptr, double)`); + const fn = invalidNaN ? "scr_bytes_get_or_nan" : "scr_bytes_get"; + host.declare(`declare double @${fn}(ptr, double)`); const result = B.tmp(); - B.line(`${result} = call double @scr_bytes_get(ptr ${receiver}, double ${index.name})`); - host.emitPendingCheck(); + B.line(`${result} = call double @${fn}(ptr ${receiver}, double ${index.name})`); return { name: result, type: F64 }; } - const idx = host.emitBytesIndex(receiver, index, expr, inBounds); + // Any invalid index traps (the array runtime's discipline). + if (!invalidNaN) return emitBytesLoad(host, elem, receiver, index, expr, inBounds, undefined); + // The numeric view of an ordinary read: an invalid index never touches + // storage and reads as NaN — ToNumber of Node's undefined. + const invalid = B.newLabel("bytes.read.invalid"); + const done = B.newLabel("bytes.read.done"); + const loaded = emitBytesLoad(host, elem, receiver, index, expr, inBounds, invalid); + const ok = B.newLabel("bytes.read.ok"); + B.br(ok); + B.startBlock(ok); + B.br(done); + B.startBlock(invalid); + B.br(done); + B.startBlock(done); + const out = B.tmp(); + B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`); + // NaN has no integer value: the integer hint survives only when the read + // is proven valid (the invalid block is then unreachable). + if (loaded.uint32 === undefined || !inBounds) return { name: out, type: F64 }; + const u32 = B.tmp(); + B.line(`${u32} = phi i32 [ ${loaded.uint32}, %${ok} ], [ 0, %${invalid} ]`); + return { name: out, type: F64, uint32: u32 }; +} + +function emitBytesLoad( + host: LlvmEmitterContext, + elem: IrBytesElem, + receiver: string, + index: LlValue, + expr: IrExpr | undefined, + inBounds: boolean, + invalid: string | undefined, +): LlValue { + const B = host.B; + const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds); const data = host.emitBytesData(receiver); const p = B.tmp(); if (BYTES_ELEMENT_SIZE[elem] < 4) { @@ -503,6 +534,7 @@ export function emitBytesIntrinsic( args[0]!, e.args[0], host.bytesBounds.has(e), + e.invalidNaN === true, ); case "slice": return call( diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index ca8e964cd..4d9ba39ca 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -231,8 +231,7 @@ export function computeMayThrow(mod: IrModule): { // setFrom and the numeric read/write families throw catchable // RangeErrors (Node's bounds discipline); the rest trap or // cannot fail. - // An out-of-range `get` throws the catchable RangeError too. - if (MAY_THROW_BYTES_METHODS.has(rec.method) || rec.method === "get") { + if (MAY_THROW_BYTES_METHODS.has(rec.method)) { f.throws = true; } break; diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 9c42779af..f281fed48 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -226,6 +226,108 @@ export function lowerSafeIndexRead( return { kind: "call", callee: name, args: [arr, index], type: resultT, loc }; } +/** An ordinary typed-array element read `t[i]`: like Node, an index that + * is not an integer in [0, length) answers undefined (the storage is + * never touched out of bounds). The result is `number | undefined`, the + * same optional shape ordinary array reads carry; numeric consumers fuse + * back to the plain element read (tryLowerNumericIndexRead), where the + * invalid case reads as NaN — exactly ToNumber(undefined). */ +export function lowerSafeBytesRead( + lowerer: Lowerer, + receiver: IrExpr, + index: IrExpr, + loc: SrcLoc, +): IrExpr { + const bytesT = receiver.type; + if (bytesT.kind !== "bytes") throw new InternalCompilerError("typed read requires bytes"); + const resultT = lowerer.withUndefinedArm(F64); + const key = `bytesIdxOr:${typeKey(bytesT)}`; + let name = lowerer.arrHofHelpers.get(key); + if (!name) { + name = `%bytes.idxOr.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const b = varRef("b.0", bytesT, loc); + const i = varRef("i.0", F64, loc); + const cmp = (op: "<" | ">=" | "===", left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op, + left, + right, + type: BOOL, + loc, + }); + const inRange: IrExpr = { + kind: "logical", + op: "&&", + left: cmp(">=", i, { kind: "numLit", value: 0, type: F64, loc }), + right: { + kind: "logical", + op: "&&", + left: cmp("<", i, { + kind: "bytesIntrinsic", + method: "length", + receiver: b, + args: [], + type: F64, + loc, + }), + right: cmp("===", { kind: "libCall", fn: "math.trunc", args: [i], type: F64, loc }, i), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + const read: IrExpr = { + kind: "bytesIntrinsic", + method: "get", + receiver: b, + args: [i], + type: F64, + loc, + }; + lowerer.liftedFns.push({ + name, + params: [ + { localId: "b.0", name: "b", type: bytesT }, + { localId: "i.0", name: "i", type: F64 }, + ], + returnType: resultT, + locals: [ + { id: "b.0", name: "b", type: bytesT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: false }, + ], + body: [ + { + kind: "return", + value: { + kind: "ternary", + cond: inRange, + then: lowerer.coerceToExpected(read, resultT), + else_: lowerer.wrappedUndefined(resultT, loc)!, + type: resultT, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc }; +} + +/** Whether `operand` is a call of the typed-array read helper. */ +function isBytesReadHelperCall(lowerer: Lowerer, operand: IrExpr): boolean { + if (operand.kind !== "call" || operand.args.length !== 2) return false; + const receiver = operand.args[0]!; + return ( + receiver.type.kind === "bytes" && + operand.args[1]!.type.kind === "f64" && + operand.callee === lowerer.arrHofHelpers.get(`bytesIdxOr:${typeKey(receiver.type)}`) + ); +} + /** Fuse only our own f64-array read helper with immediate ToNumber. * Reuse its arguments verbatim so the receiver and index still evaluate * exactly once, in order, before the read. Do not specialize user calls, @@ -235,6 +337,20 @@ export function tryLowerNumericIndexRead( operand: IrExpr, loc: SrcLoc, ): IrExpr | null { + if (operand.kind === "call" && isBytesReadHelperCall(lowerer, operand)) { + // The plain element read: an invalid index reads as NaN, which is + // ToNumber(undefined). + const [receiver, index] = operand.args; + return { + kind: "bytesIntrinsic", + method: "get", + receiver: receiver!, + args: [index!], + type: F64, + loc, + invalidNaN: true, + }; + } if ( operand.kind !== "call" || operand.callee !== lowerer.arrHofHelpers.get(`idxOr:${typeKey(F64)}`) @@ -275,6 +391,11 @@ export function tryLowerIndexTruthiness( loc: SrcLoc, ): IrExpr | null { if (operand.kind !== "call" || operand.args.length !== 2) return null; + if (isBytesReadHelperCall(lowerer, operand)) { + // NaN (the invalid read) is falsy exactly like undefined. + const number = tryLowerNumericIndexRead(lowerer, operand, loc); + return number ? lowerer.ensureBool(number, node) : null; + } const [arr, index] = operand.args; if (arr?.type.kind !== "array" || index?.type.kind !== "f64") return null; const elem = arr.type.elem; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 4f38645e1..379eb9c35 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -110,6 +110,7 @@ import { lowerSafeIndexRead, tryLowerIndexTruthiness, tryLowerNumericIndexRead, + lowerSafeBytesRead, } from "./array-values.js"; import { strCharsCall } from "./containers/array-construction.js"; import { lowerOptionalStringIndex } from "./string-index.js"; @@ -6661,14 +6662,21 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre if (index.type.kind !== "f64") { lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); } - return { - kind: "bytesIntrinsic", - method: "get", - receiver: recv, - args: [index], - type: F64, - loc: locOf(expr), - }; + // `t[i]!` asserts presence: the numeric element read (NaN when + // invalid). Every other read answers undefined for an invalid index, + // like Node. + if (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) { + return { + kind: "bytesIntrinsic", + method: "get", + receiver: recv, + args: [index], + type: F64, + loc: locOf(expr), + invalidNaN: true, + }; + } + return lowerSafeBytesRead(lowerer, recv, index, locOf(expr)); } // Tuple element read `t[0]`: a positional-field read of the tuple's // record shape — LITERAL indices only (the checker's per-index types @@ -9225,7 +9233,15 @@ export function lowerIncDec( const read: IrExpr = type.kind === "array" ? arrayValueRead(lowerer, receiver, index, type.elem, loc) - : { kind: "bytesIntrinsic", method: "get", receiver, args: [index], type: F64, loc }; + : { + kind: "bytesIntrinsic", + method: "get", + receiver, + args: [index], + type: F64, + loc, + invalidNaN: true, + }; return finish(read, (value) => type.kind === "array" ? arrayValueStore( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3898d4740..59691a431 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3993,7 +3993,8 @@ export class Lowerer { if (!ts.isElementAccessExpression(e)) return false; const kind = this.mapTypeOf(this.typeOf(e.expression))?.kind; if (kind === "string") return !this.stringIndexBounds.inBounds(e); - return kind === "array"; + // Typed-array reads answer undefined for an invalid index too. + return kind === "array" || kind === "bytes"; }; const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const read = peel(node); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 89b01532c..8f1ab3e15 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -268,7 +268,7 @@ export function analyzeIntegerRanges(fn: IrFunction): IntegerRanges { if (numeric && !numeric.write) range = byteNumberRange(numeric); else if (e.method === "length" || e.method === "byteLength" || e.method === "byteOffset") range = { min: 0, max: Number.MAX_SAFE_INTEGER }; - else if (e.method === "get" && e.receiver.type.kind === "bytes") { + else if (e.method === "get" && e.invalidNaN !== true && e.receiver.type.kind === "bytes") { const elem = e.receiver.type.elem; if (elem === "u8" || elem === "u8c") range = { min: 0, max: 255 }; else if (elem === "i8") range = { min: -128, max: 127 }; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 034db9d84..aa8ea2a1f 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2500,8 +2500,10 @@ export type IrExpr = /** Typed-array/Buffer method or property on a bytes receiver — see * IrBytesIntrinsicMethod for the surface and conventions. Methods in * MAY_THROW_BYTES_METHODS raise catchable RangeErrors (may-throw - * seeds); `get` on an invalid index throws a catchable RangeError too - * (a typed read has no undefined to answer with; also a seed). */ + * seeds); `get` traps on an invalid index unless `invalidNaN` is set, + * in which case the invalid read answers NaN without touching storage + * (the numeric view of Node's undefined; ordinary reads test the index + * first and answer undefined itself). */ | { kind: "bytesIntrinsic"; method: IrBytesIntrinsicMethod; @@ -2509,6 +2511,7 @@ export type IrExpr = args: IrExpr[]; type: IrType; loc: SrcLoc; + invalidNaN?: true; } /** `new Map()` — allocate an empty map. `type` is the map type * (key/value fences already enforced by the frontend); the result is diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7178a8657..3a87bd7c2 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -254,22 +254,16 @@ double scr_bytes_byte_len(const ScrBytes *b) { /* ── element access ──────────────────────────────────────────────────── * Invalid writes are ignored. Typed numeric reads cannot represent - * undefined (Node's answer), so an invalid read throws a catchable - * RangeError instead (the caller checks the pending exception; the NaN - * result is never observed); checked reads return undefined. */ + * undefined, so invalid reads trap; checked reads return undefined. */ -static bool scr_bytes_check_index(const ScrBytes *b, double i, size_t *out) { +static size_t scr_bytes_check_index(const ScrBytes *b, double i) { if (!(i >= 0) || i != trunc(i) || i >= (double)b->len) { char buf[32]; scr_f64_to_str(i, buf); - char msg[96]; - int len = snprintf(msg, sizeof msg, "typed array index %s out of bounds (length %zu)", buf, - b->len); - scr_throw_error_msg(SCR_ERR_RANGE, msg, (size_t)len); - return false; + scr_trap_fmt("scriptc: RangeError: typed array index %s out of bounds (length %zu)\n", + buf, b->len); } - *out = (size_t)i; - return true; + return (size_t)i; } /* ToUint32: NaN/±Infinity → 0, truncate toward zero, wrap mod 2^32. @@ -280,8 +274,7 @@ static uint32_t scr_bytes_to_u32(double v) { double scr_bytes_get(const ScrBytes *b, double i) { SCR_SHARED_GUARD(b, NULL); - size_t idx; - if (!scr_bytes_check_index(b, i, &idx)) return NAN; + size_t idx = scr_bytes_check_index(b, i); switch (b->elem) { case SCR_BYTES_U8: case SCR_BYTES_U8C: return (double)b->data[idx]; @@ -324,6 +317,14 @@ double scr_bytes_get(const ScrBytes *b, double i) { return 0; /* unreachable */ } +/* The numeric view of an ordinary typed-array read: Node answers undefined + * for an index that is not an integer in [0, length), which ToNumber turns + * into NaN. Never touches storage out of bounds. */ +double scr_bytes_get_or_nan(const ScrBytes *b, double i) { + if (!(i >= 0) || i != trunc(i) || i >= (double)b->len) return NAN; + return scr_bytes_get(b, i); +} + void scr_bytes_set(ScrBytes *b, double i, double v) { SCR_SHARED_GUARD(b, NULL); if (!(i >= 0) || i != trunc(i) || i >= (double)b->len) return; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index d1e620968..84e517c34 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -6404,6 +6404,7 @@ void scr_dataview_set(ScrBytes *b, double byte_off, double value, ScrDataViewGet * JS-exactly: u8/u32 by modular truncation (NaN/±Infinity → 0, truncate * toward zero, wrap mod 2^8/2^32), f32 by double→float rounding. */ double scr_bytes_get(const ScrBytes *b, double i); +double scr_bytes_get_or_nan(const ScrBytes *b, double i); /* NaN when the index is invalid */ void scr_bytes_set(ScrBytes *b, double i, double v); /* Replace a live typed-array capsule's fixed-size payload from its dyn * snapshot. Source and target have the same compiler-checked bytes type. */ diff --git a/packages/runtime/test/bytes.test.ts b/packages/runtime/test/bytes.test.ts index 56b7e16f1..9c4722161 100644 --- a/packages/runtime/test/bytes.test.ts +++ b/packages/runtime/test/bytes.test.ts @@ -74,13 +74,18 @@ test("bytes runtime: coercions, encodings, zlib, fs, RC", async () => { expect(stderr.trim().split("\n").pop()).toMatch(/^(\d+)\/\1 cases passed$/); }); -// Statically typed numeric reads cannot represent undefined; an invalid -// index throws a catchable RangeError instead of aborting. Invalid writes -// are ignored and covered by the runtime assertions above. +// Statically typed numeric reads cannot represent undefined and still trap. +// Invalid writes are ignored and covered by the runtime assertions above. test.each([ ["--crash-get-oob", "typed array index 1 out of bounds (length 1)"], ["--crash-get-frac", "typed array index 0.5 out of bounds (length 1)"], -])("invalid reads throw a catchable RangeError (%s)", async (mode, message) => { - const { stderr } = await execFileAsync(bin, [mode]); - expect(stderr).toContain(`Uncaught RangeError: ${message}`); +])("trap aborts (%s)", async (mode, message) => { + const err = await execFileAsync(bin, [mode]).then( + () => { + throw new Error(`expected ${mode} to abort`); + }, + (e: Error & { signal?: string; stderr?: string }) => e, + ); + expect(err.signal).toBe("SIGABRT"); + expect(err.stderr).toContain(`scriptc: RangeError: ${message}`); }); diff --git a/packages/runtime/test/test_bytes.c b/packages/runtime/test/test_bytes.c index 9f0d470ec..9b4b68ffc 100644 --- a/packages/runtime/test/test_bytes.c +++ b/packages/runtime/test/test_bytes.c @@ -3,8 +3,8 @@ * bytes.test.ts, which also asserts the trap modes abort: * * run all assertions; prints "N/N cases passed" - * --crash-get-oob element read past the end → pending RangeError - * --crash-get-frac fractional element index → pending RangeError + * --crash-get-oob element read past the end → RangeError + abort() + * --crash-get-frac fractional element index → RangeError + abort() * * The coercion matrix mirrors Node exactly (verified by hand and by the * differential corpus): ToUint8/ToUint32 modular truncation on writes, @@ -393,13 +393,8 @@ int main(int argc, char **argv) { fprintf(stderr, "unknown mode %s\n", argv[1]); return 2; } - if (!scr_exc_pending()) { - fprintf(stderr, "expected a pending RangeError\n"); - return 2; - } - scr_exc_print_uncaught(); - scr_bytes_release(b); - return 0; + fprintf(stderr, "expected a trap, still alive\n"); + return 2; } if (argc != 2) { fprintf(stderr, "usage: test_bytes | --crash-*\n"); diff --git a/tests/corpus/repeat-and-typed-read-errors.ts b/tests/corpus/repeat-and-typed-read-errors.ts index f4cf5a31f..3a3c6318d 100644 --- a/tests/corpus/repeat-and-typed-read-errors.ts +++ b/tests/corpus/repeat-and-typed-read-errors.ts @@ -1,6 +1,6 @@ -// "x".repeat with a negative or infinite count and an out-of-range typed -// array read throw catchable RangeErrors: the program keeps running after -// the catch. +// "x".repeat with a negative or infinite count throws a catchable +// RangeError, and typed-array reads at an index that is not an integer in +// [0, length) answer undefined (writes there are ignored), like Node. function attempt(label: string, f: () => unknown): void { try { console.log(label, "ok", f()); @@ -24,5 +24,30 @@ try { const bytes = new Uint8Array(4); bytes[1] = 9; -console.log("in range", bytes[1]); +const far: number = process.argv.length + 10; +const negative: number = -process.argv.length; +const fraction: number = process.argv.length + 0.5; +console.log("in range", bytes[1], bytes[3]); +console.log("out of range", bytes[far], bytes[negative], bytes[fraction], bytes[4]); +console.log("is undefined", bytes[far] === undefined, bytes[1] === undefined); +console.log("arithmetic", bytes[far] + 1, bytes[1] + 1, Number.isNaN(bytes[far] * 2)); +console.log("defaulted", bytes[far] ?? -1, bytes[1] ?? -1); +if (bytes[far]) console.log("not reached"); +else console.log("falsy"); +const missing = bytes[far]; +console.log("binding", missing, `${missing}`, [missing], JSON.stringify({ value: bytes[far] })); +bytes[far] = 7; +bytes[negative] = 7; +bytes[fraction] = 7; +console.log("ignored writes", bytes.join(","), bytes.length); + +const floats = new Float64Array([1.5, -2]); +let sum = 0; +for (let i = 0; i <= floats.length; i++) sum += floats[i] !== undefined ? floats[i]! : 100; +console.log("floats", sum, floats[2], floats[-0]); +const words = new Int32Array(2); +words[0] = -5; +const picked: number[] = []; +for (let i = -1; i < 3; i++) picked.push(words[i] ?? 0); +console.log("words", picked.join(","), words.length); console.log("after", bytes.length); diff --git a/tests/corpus/worker-class-instance-clone.ts b/tests/corpus/worker-class-instance-clone.ts index 73f774b8c..8d0950ccb 100644 --- a/tests/corpus/worker-class-instance-clone.ts +++ b/tests/corpus/worker-class-instance-clone.ts @@ -2,7 +2,8 @@ // as a structured clone: a plain object with the instance's own enumerable // data properties. Methods, accessors, private fields and the prototype // chain are not transferred, so instanceof is false in the receiver, and -// repeated references inside one message stay one object. +// repeated references inside one message stay one object. A frozen +// message arrives unfrozen. import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; class Fixture { @@ -49,13 +50,15 @@ if (isMainThread) { else console.log("reply", inspect(message)); }); worker.on("exit", (code: number) => console.log("exit", code, lamp.describe(), lamp.label)); - worker.postMessage([lamp, lamp]); + worker.postMessage(Object.freeze([lamp, lamp])); } else { const port = parentPort!; - port.postMessage(`workerData ${inspect(workerData)}`); + port.postMessage(`workerData ${inspect(workerData)} ${Object.isFrozen(workerData)}`); port.once("message", (message: unknown) => { const pair = message as unknown[]; - port.postMessage(`pair ${pair.length} ${pair[0] === pair[1]} ${inspect(pair[0])}`); + port.postMessage( + `pair ${pair.length} ${pair[0] === pair[1]} ${Object.isFrozen(message)} ${inspect(pair[0])}`, + ); port.postMessage(new Lamp("floor", ["base"])); port.close(); }); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 44486c7b3..f82f7f4fa 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -170,25 +170,6 @@ throw v; expect(num.stderr).toBe("Uncaught 42\n"); }); - test("an out-of-range typed array read throws a catchable RangeError", async () => { - // Node answers undefined; a typed numeric read cannot, so the native - // program throws a catchable RangeError instead of aborting. - const r = await compileAndRun( - "typed-read-out-of-range", - `const t = new Uint8Array(4); -const i: number = process.argv.length + 10; -try { - console.log(t[i]); -} catch (e) { - console.log(e instanceof RangeError, (e as Error).message); -} -console.log("after", t.length); -`, - ); - expect(r.exitCode).toBe(0); - expect(r.stdout).toMatch(/^true typed array index \d+ out of bounds \(length 4\)\nafter 4\n$/); - }); - test("an unhandled ERROR rejection renders name: message too", async () => { const r = await compileAndRun( "unhandled-error-rejection",