From 1501559d4a9ac8c2f4b6d86732b85d1cf15c8add Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:34:06 -0500 Subject: [PATCH] Read and write the "undefined" property for element indexes that are undefined An element index the checker types as a number can hold undefined at run time (an element read from a holey array). JavaScript then names the array's "undefined" property, but the index converted with ToNumber, so such reads and writes used the "NaN" property: Node prints `10 0,7,0,7` for a program that sets `values[NaN] = 10` and then writes through two holes, the compiled program printed `7 0,7,0,7`. The runtime's noncanonical property table, which holds the properties named by non-index numbers, now spells one NaN payload (scr_arr_undefined_key) "undefined". At array element reads, writes, compound and update writes, destructuring targets and writes in value position, an index that can be undefined is that key when it is undefined. The property stays a typed entry of the array, so the Int32[] contract checks cover it like any element store. A typed array cannot hold the property: a write through an undefined index throws the catchable SC1090 error where Node adds it. Fixes #1640. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../containers/array-callback-call.ts | 4 +- .../frontend/lowering/expressions/binary.ts | 43 ++++++-- .../lowering/expressions/element-access.ts | 98 ++++++++++++++--- .../frontend/lowering/expressions/updates.ts | 45 ++++++-- .../src/frontend/lowering/lower-stmts.ts | 4 +- .../src/frontend/lowering/undefined-index.ts | 101 ++++++++++++++++++ .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/ir/ir.ts | 5 +- .../7540-undefined-element-index-keys.ts.json | 6 ++ packages/runtime/src/scr_array.c | 21 +++- packages/runtime/src/scr_array.h | 10 +- .../7540-undefined-element-index-keys.ts | 53 +++++++++ .../7540-undefined-element-index-keys.ts.json | 1 + tests/harness/errors.test.ts | 20 ++++ 15 files changed, 374 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/undefined-index.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json create mode 100644 tests/corpus/7540-undefined-element-index-keys.ts create mode 100644 tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8e7c74cad..c3d54aac1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1583,6 +1583,7 @@ export const LIB_FN_SYMS: Record = { "regex.resultProp": "scr_regex_result_prop", "array.namedKeys": "scr_arr_named_keys", "array.hasNamedKey": "scr_arr_has_named_key", + "array.undefinedKey": "scr_arr_undefined_key", // queueMicrotask's checked-dynamic form (borrowed dyn; a non-function // throws synchronously), the minted setImmediate value, and the // timers/promises immediate — all mark the loop live. diff --git a/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts index a22a82bb1..b98000fa1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts @@ -1,10 +1,10 @@ import * as ts from "../../ts7/adapter.js"; +import { lowerOptionalIndex } from "../undefined-index.js"; import { locOf } from "../../program.js"; import { varRef } from "../../../ir/build.js"; import { DYN, type IrExpr, type IrStmt, isUnitType } from "../../../ir/ir.js"; import type { Lowerer } from "../lowerer.js"; import { completeFuncValueArgs } from "../call-arguments.js"; -import { lowerOptionalNumber } from "../lower-exprs.js"; import { lowerSafeIndexRead } from "../array-values.js"; /** Retain native function slots during indexed calls. The array remains @@ -37,7 +37,7 @@ export function lowerArrayCallbackCall( return varRef(local.id, local.type, loc); }; const receiver = store(array, "%callbackArray"); - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(access.argumentExpression), locOf(access.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 9702a6dd9..58f6e16b5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1,6 +1,11 @@ /* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */ import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { cleanFuncAdaptable } from "../coercions/function-adapters.js"; import { type Lowerer, jsFuncNameOf } from "../lowerer.js"; import { @@ -1160,12 +1165,26 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } if (recv?.type.kind === "array" || recv?.type.kind === "bytes") { const receiver = lowerer.declareHiddenLocal("%setArray", recv.type); - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(expr.left.argumentExpression), - locOf(expr.left.argumentExpression), - expr.left.argumentExpression, - ); + // An undefined index names the "undefined" property + // (undefined-index.ts), which a typed array cannot hold. + const rawIndex = lowerer.lowerExpr(expr.left.argumentExpression); + const split = + recv.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + rawIndex, + expr.left.argumentExpression, + locOf(expr.left.argumentExpression), + ) + : null; + const index = + split?.index ?? + lowerOptionalIndex( + lowerer, + rawIndex, + locOf(expr.left.argumentExpression), + expr.left.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported( "SC1090", @@ -1178,7 +1197,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const result = varRef(value.id, raw.type, loc); const arr = varRef(receiver.id, recv.type, loc); const idx = varRef(key.id, F64, loc); - const write: IrStmt = + const store: IrStmt = recv.type.kind === "array" ? arrayValueStore(lowerer, arr, idx, result, recv.type.elem, loc) : { @@ -1188,10 +1207,20 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr value: lowerer.coerceInto(expr.right, result, F64), loc, }; + const write: IrStmt = split + ? { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, expr.left, loc)], + else_: [store], + loc, + } + : store; return { kind: "seqExpr", stmts: [ { kind: "varDecl", localId: receiver.id, init: recv, loc }, + ...(split?.stmts ?? []), { kind: "varDecl", localId: key.id, init: index, loc }, { kind: "varDecl", localId: value.id, init: raw, loc }, write, diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 045bebdf4..16cc8f845 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1,5 +1,10 @@ /* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */ import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { type Lowerer, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js"; import { BOOL, @@ -774,9 +779,9 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre } } // Array reads can themselves supply an index. A missing numeric value - // is undefined, whose property lookup misses just like NaN; keep that - // value instead of rejecting a checker-number index with optional IR. - const index = lowerOptionalNumber( + // is undefined, which names the array's "undefined" property + // (undefined-index.ts). + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(expr.argumentExpression), locOf(expr.argumentExpression), @@ -1518,12 +1523,26 @@ export function lowerElementCompoundParts( "compound assignment through a non-native array or byte view", ); } - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(target.argumentExpression), - locOf(target.argumentExpression), - target.argumentExpression, - ); + // An undefined index names the "undefined" property (undefined-index.ts), + // which a typed array cannot hold. + const rawIndex = lowerer.lowerExpr(target.argumentExpression); + const split = + receiver.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + rawIndex, + target.argumentExpression, + locOf(target.argumentExpression), + ) + : null; + const index = + split?.index ?? + lowerOptionalIndex( + lowerer, + rawIndex, + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); @@ -1632,15 +1651,25 @@ export function lowerElementCompoundParts( } const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type); const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc); - const write: IrStmt = + const store: IrStmt = receiver.type.kind === "array" ? arrayValueStore(lowerer, receiverRef(), indexRef(), resultRef(), receiver.type.elem, loc) : { kind: "bytesSet", arr: receiverRef(), index: indexRef(), value: resultRef(), loc }; + const write: IrStmt = split + ? { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, target, loc)], + else_: [store], + loc, + } + : store; return { expr: { kind: "seqExpr", stmts: [ { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + ...(split?.stmts ?? []), { kind: "varDecl", localId: indexLocal.id, init: index, loc }, { kind: "varDecl", localId: oldLocal.id, init: oldValue, loc }, { kind: "varDecl", localId: resultLocal.id, init: computed, loc }, @@ -1867,13 +1896,23 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): // The checker's flow type is a typed array, but the storage is wider. if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); // An index the checker types as a number but whose storage may hold - // undefined (a hole read) converts with ToNumber, as the reads do. - const index = lowerOptionalNumber( + // undefined (a hole read) converts with ToNumber, as the reads do; an + // undefined index names a property the typed array cannot hold. + const rawIndex = lowerer.lowerExpr(target.argumentExpression); + const split = splitUndefinedIndex( lowerer, - lowerer.lowerExpr(target.argumentExpression), - locOf(target.argumentExpression), + rawIndex, target.argumentExpression, + locOf(target.argumentExpression), ); + const index = + split?.index ?? + lowerOptionalNumber( + lowerer, + rawIndex, + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind === "dyn" && recv.type.kind === "bytes") { return { kind: "exprStmt", @@ -1889,7 +1928,34 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); } const value = lowerer.lowerExprExpecting(expr.right, F64); - return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; + if (split === null) return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; + const loc = locOf(expr); + const receiver = lowerer.declareHiddenLocal("%indexedBytes", recv.type); + const valueLocal = lowerer.declareHiddenLocal("%elementValue", F64); + return { + kind: "block", + body: [ + { kind: "varDecl", localId: receiver.id, init: recv, loc }, + ...split.stmts, + { kind: "varDecl", localId: valueLocal.id, init: value, loc }, + { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, target, loc)], + else_: [ + { + kind: "bytesSet", + arr: varRef(receiver.id, recv.type, loc), + index, + value: varRef(valueLocal.id, F64, loc), + loc, + }, + ], + loc, + }, + ], + loc, + }; } // Tuple element write `t[0] = v`: a positional-field write (recordSet) // — literal indices only, like the read. @@ -2184,7 +2250,7 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): "assignment through a possibly missing nested array receiver", ); } - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(target.argumentExpression), locOf(target.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 128093dad..04ca23a96 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,5 +1,10 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { isStrictCode } from "../strict-mode.js"; import { isPrivateWriteReceiver } from "../private-brands.js"; import type { Lowerer } from "../lowerer.js"; @@ -311,12 +316,27 @@ export function lowerIncDec( (raw.type.kind === "array" || raw.type.kind === "bytes") ) { const receiver = save(raw, "%incrementArray"); - const rawIndex = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(access.argumentExpression), - locOf(access.argumentExpression), - access.argumentExpression, - ); + // An undefined index names the "undefined" property + // (undefined-index.ts), which a typed array cannot hold. + const loweredIndex = lowerer.lowerExpr(access.argumentExpression); + const split = + raw.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + loweredIndex, + access.argumentExpression, + locOf(access.argumentExpression), + ) + : null; + if (split) stmts.push(...split.stmts); + const rawIndex = + split?.index ?? + lowerOptionalIndex( + lowerer, + loweredIndex, + locOf(access.argumentExpression), + access.argumentExpression, + ); const index = save( rawIndex.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) ? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc } @@ -340,7 +360,7 @@ export function lowerIncDec( loc, invalidNaN: true, }; - return finish(read, (value) => + const store = (value: IrExpr): IrStmt => type.kind === "array" ? arrayValueStore( lowerer, @@ -356,8 +376,15 @@ export function lowerIncDec( index, value: lowerer.coerceInto(access, value, F64), loc, - }, - ); + }; + if (!split) return finish(read, store); + return finish(read, (value) => ({ + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, access, loc)], + else_: [store(value)], + loc, + })); } // A TS member statically typed number on a checked-dynamic receiver (a // class-backed protocol view): the expanded form `o.v = o.v + 1`, its diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 62c2e3a0f..da1bd19ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -25,6 +25,7 @@ import { InternalCompilerError } from "../../errors.js"; * flow (if/while/for/for-of/do, switch, try/catch, abrupt completions), * explicit resource management, and blocked-binding poisoning. */ import * as ts from "../ts7/adapter.js"; +import { lowerOptionalIndex } from "./undefined-index.js"; import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; @@ -204,7 +205,6 @@ import { lowerElementCompoundStatement, lowerIncDec, lowerIncDecTarget, - lowerOptionalNumber, lowerGroupsProjection, matchResultNamedGroupsOf, runtimeOptionalGuardIds, @@ -9279,7 +9279,7 @@ function lowerAssignTargetInto( if (arr.type.kind === "array") { const recv = lowerer.declareHiddenLocal("%dtRecv", arr.type); out.push({ kind: "varDecl", localId: recv.id, init: arr, loc }); - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(t.argumentExpression), locOf(t.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/undefined-index.ts b/packages/compiler/src/frontend/lowering/undefined-index.ts new file mode 100644 index 000000000..5226d7008 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/undefined-index.ts @@ -0,0 +1,101 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, F64, UNDEFINED_T, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import type { Lowerer } from "./lowerer.js"; +import { lowerOptionalNumber } from "./lower-exprs.js"; +import { keyedHelperName } from "./stable-names.js"; + +/** An element index the checker types as a number can be undefined at run + * time (an element read from a holey array). JavaScript then names the + * property "undefined", not "NaN". */ + +/** The array element index `operand` (the lowered index of `node`) as a + * number. An undefined index is the runtime's "undefined" key + * (`array.undefinedKey`, a NaN the array's property table spells + * "undefined"); other values convert with ToNumber, as lowerOptionalNumber + * does. */ +export function lowerOptionalIndex( + lowerer: Lowerer, + operand: IrExpr, + loc: SrcLoc, + node: ts.Expression, +): IrExpr { + const split = splitUndefinedIndex(lowerer, operand, node, loc); + if (split === null) return lowerOptionalNumber(lowerer, operand, loc, node); + return { + kind: "seqExpr", + stmts: split.stmts, + result: { + kind: "ternary", + cond: split.isUndefined, + then: { kind: "libCall", fn: "array.undefinedKey", args: [], type: F64, loc }, + else_: split.index, + type: F64, + loc, + }, + type: F64, + loc, + }; +} + +/** A typed-array element index that can be undefined: `stmts` evaluate it + * once, `isUndefined` tests it, and `index` is the number a defined index + * converts to. */ +export interface SplitIndex { + stmts: IrStmt[]; + isUndefined: IrExpr; + index: IrExpr; +} + +/** Splits `raw`, the lowered index of `node`, when it is a number that can + * be undefined; null otherwise. */ +export function splitUndefinedIndex( + lowerer: Lowerer, + raw: IrExpr, + node: ts.Expression, + loc: SrcLoc, +): SplitIndex | null { + if (raw.type.kind !== "union") return null; + const undefinedTag = lowerer.armTag(raw.type.unionId, UNDEFINED_T); + if (undefinedTag < 0) return null; + const local = lowerer.declareHiddenLocal("%elementIndex", raw.type); + const stable = varRef(local.id, raw.type, loc); + const index = lowerOptionalNumber(lowerer, stable, loc, node); + if (index.type.kind !== "f64") return null; + return { + stmts: [{ kind: "varDecl", localId: local.id, init: raw, loc }], + isUndefined: { + kind: "unionIsTag", + unionId: raw.type.unionId, + tag: undefinedTag, + negated: false, + value: stable, + type: BOOL, + loc, + }, + index, + }; +} + +/** A typed array has no property named "undefined" to store into: the + * store throws the catchable SC1090 error when it runs. A call, so that it + * can sit among an expression's statements. */ +export function undefinedTypedArrayKeyFence(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrStmt { + const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(node), loc.start).line + 1; + const message = `typed array properties named 'undefined' (an element index that is undefined) are not supported yet [SC1090 at ${loc.file}:${line}]`; + const key = `index.undefinedFence:${message}`; + let name = lowerer.arrHofHelpers.get(key); + if (name === undefined) { + name = keyedHelperName(lowerer, "%index.undefinedFence", key); + lowerer.arrHofHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [{ kind: "runtimeFence", code: "SC1090", message, loc }], + loc, + }); + } + return { kind: "exprStmt", expr: { kind: "call", callee: name, args: [], type: DYN, loc }, loc }; +} diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f5da56818..51a0ec778 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3813,6 +3813,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // Whether the array (args[0]) still has the key (args[1]) from its // namedKeys list. Borrowed; never throws. "array.hasNamedKey": { argTypes: [null, STRING], result: BOOL }, + // The key of an element access whose index is undefined (the property + // named "undefined"; frontend/lowering/undefined-index.ts). + "array.undefinedKey": { argTypes: [], result: F64 }, // node:events EventEmitter: receivers are emitter-hierarchy objects and // the chaining forms (on/off/removeAll/setMax) return the receiver's // own class — program-dependent object types, checked in the libCall diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 0fa873a16..8c3ead512 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2099,8 +2099,9 @@ export type IrStmt = * and whose `code` carries the SC diagnostic code; unwinds exactly like * `throw`. * TypeScript sources produce it only as the tsc-unreachable fallthrough - * trap (appendImplicitUndefinedReturn's SC9002) — their construct fences - * stay compile errors. */ + * trap (appendImplicitUndefinedReturn's SC9002) and for a typed-array + * store through an element index that is undefined at run time + * (undefined-index.ts); their construct fences stay compile errors. */ | { kind: "runtimeFence"; code: string; message: string; loc: SrcLoc } /** Rethrow of a catch binding (`throw e` where e is the binding): * re-raises the SAVED exception exactly — kind and payload preserved, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json new file mode 100644 index 000000000..528758c4e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7540-undefined-element-index-keys.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index bbe66e26e..f4092e75b 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -176,7 +176,22 @@ static size_t scr_arr_prop_index(const ScrArr *a, const char *key, return SIZE_MAX; } +#define SCR_ARR_UNDEFINED_KEY_BITS UINT64_C(0x7ff8000000756e64) + +double scr_arr_undefined_key(void) { + uint64_t bits = SCR_ARR_UNDEFINED_KEY_BITS; + double key; + memcpy(&key, &bits, sizeof key); + return key; +} + static size_t scr_arr_prop_key(double i, char *buf) { + uint64_t bits; + memcpy(&bits, &i, sizeof bits); + if (bits == SCR_ARR_UNDEFINED_KEY_BITS) { + memcpy(buf, "undefined", sizeof "undefined"); + return sizeof "undefined" - 1; + } return scr_f64_to_str(i, buf); } @@ -2012,7 +2027,7 @@ static uint64_t scr_arr_require_slot(ScrArr *a, double i) { if (scr_arr_prop_get_state(a, i, &prop, &state)) { if (state == SCR_ARR_UNDEFINED) { char buf[32]; - scr_f64_to_str(i, buf); + scr_arr_prop_key(i, buf); scr_trap_fmt("scriptc: RangeError: numeric property %s is undefined\n", buf); } return prop; @@ -5121,6 +5136,10 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) { bool scr_arr_numeric_key(const char *key, size_t len, double *out) { char text[64]; + if (len == sizeof "undefined" - 1 && memcmp(key, "undefined", len) == 0) { + *out = scr_arr_undefined_key(); + return true; + } if (len == 0 || len >= sizeof text) return false; memcpy(text, key, len); text[len] = '\0'; diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 6034462e9..1750e8509 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -457,9 +457,15 @@ void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target); * properties of `target`, so neither is lost when the storage is swapped. */ void scr_arr_adopt_view_properties(ScrArr *a, struct ScrDyn *view, ScrArr *target); /* True when `key` is the string spelling of a number that is not an array - * index ("-1", "1.5", "NaN", "Infinity"), with the number in *out. Arrays - * keep such properties in their noncanonical property table. */ + * index ("-1", "1.5", "NaN", "Infinity"), or "undefined", with the number in + * *out (scr_arr_undefined_key for "undefined"). Arrays keep such properties + * in their noncanonical property table. */ bool scr_arr_numeric_key(const char *key, size_t len, double *out); +/* The number compiled code passes as the key of an element access whose + * index is undefined at run time: a NaN with its own payload, which the + * property table spells "undefined" (JavaScript names that property, not + * "NaN"). */ +double scr_arr_undefined_key(void); /* Whether the array still has the named or noncanonical numeric property * `key`: `for...in` skips a key deleted before its turn. Borrows both. */ bool scr_arr_has_named_key(ScrArr *a, ScrStr *key); diff --git a/tests/corpus/7540-undefined-element-index-keys.ts b/tests/corpus/7540-undefined-element-index-keys.ts new file mode 100644 index 000000000..8da1b591b --- /dev/null +++ b/tests/corpus/7540-undefined-element-index-keys.ts @@ -0,0 +1,53 @@ +// An element index the checker types as a number can be undefined at run +// time (an element read from a holey array). JavaScript then names the +// array's "undefined" property, not "NaN": plain, compound, update and +// destructuring writes store it, a write used as a value yields the value, +// and reads (number, string, record and array elements) read it back. +const holey: number[] = [1]; +holey[3] = 3; + +const values: number[] = [0, 0, 0, 0]; +values[NaN] = 10; +for (const i of holey) values[i] = 7; +console.log(values[NaN], values.join(",")); +let total = 0; +for (const i of holey) total += values[i]!; +console.log(total); + +const counts: number[] = [0, 0, 0, 0]; +counts[NaN] = 10; +let trace = ""; +for (const i of holey) trace += counts[i]++ + ","; +for (const i of holey) counts[i] += 2; +for (const i of holey) trace += --counts[i] + ";"; +console.log(counts[NaN], counts.join(","), trace); + +let last = 0; +for (const i of holey) last = values[i] = 5; +for (const i of holey) [values[i]] = [8]; +console.log(last, values[NaN], values.join(",")); + +const names: string[] = ["a", "b", "c", "d"]; +for (const i of holey) names[i] += "!"; +const seen: (string | undefined)[] = []; +for (const i of holey) seen.push(names[i]); +console.log(names.join("|"), seen.join("|")); + +interface Point { + x: number; +} +const points: Point[] = [{ x: 0 }, { x: 1 }]; +const shared: Point = { x: 9 }; +let same = ""; +for (const i of holey) { + points[i] = shared; + same += String(points[i] === shared) + " "; +} +shared.x = 10; +for (const i of holey) same += String(points[i]?.x) + " "; +console.log(same); + +const fresh: number[] = [1, 2]; +let missing = ""; +for (const i of holey) missing += String(fresh[i]) + " "; +console.log(missing); diff --git a/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json b/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json new file mode 100644 index 000000000..1da1b737f --- /dev/null +++ b/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 5300 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index c8b0b51b8..566a28e96 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -305,6 +305,26 @@ describe("checked catch-binding casts", () => { ); }); + test("a typed-array write through an undefined index is a named runtime fence", async () => { + // Node stores an ordinary property named "undefined" on the typed + // array, which a compiled typed array cannot hold; defined indexes + // before it store as usual. + const r = await compileAndRun( + "typed-array-undefined-index", + `const holey: number[] = [1]; +holey[3] = 3; +const cells = new Float64Array(4); +for (const i of holey) cells[i] = 9; +console.log(cells.join(",")); +`, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch( + /^Uncaught Error: typed array properties named 'undefined' \(an element index that is undefined\) are not supported yet \[SC1090 at .*typed-array-undefined-index\.ts:4\]\n$/, + ); + }); + test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => { // Effect-free surplus args drop at compile time, JS-exact (corpus // 1760 — the test-string-decoder writeSequences shape). An effectful