From ee49946d32d1cc791d19b860ff37808a89d752d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 20:53:19 -0500 Subject: [PATCH 1/2] Support array keys in InternTable and free replaced table indexes InternTable keys can be arrays of numbers or of strings, compared by their elements: numbers by SameValueZero, strings by content, and holes equal to each other only. The runtime stores an array key as one byte string that encodes its elements; the Node.js implementation encodes it into a Map key the same way. Replaced table indexes are now freed. Every thread that uses a table records the epoch it read at the start of each lookup and insert, and an index retired in an earlier epoch than every recorded one is freed. Worker threads stop holding retirements back when they exit. Number indexes keep a tag byte per slot ahead of the key and value pairs, so a lookup of an absent key reads the dense tag bytes instead of pairs. getOrInsertComputed lowers an arrow callback with an expression body in place, with its parameter bound to the key, so interning through `(key) => new T(...)` creates no closure. --- docs/content/docs/experimental.mdx | 4 +- packages/compiler/src/backend/llvm/emitter.ts | 5 +- .../src/backend/llvm/intern-tables.test.ts | 16 +- .../src/frontend/lowering/intern-tables.ts | 92 +++++- packages/compiler/src/ir/ir.ts | 3 +- packages/compiler/src/ir/validate.ts | 8 +- .../5724-intern-table-array-keys.ts.json | 6 + .../5725-intern-table-callbacks.ts.json | 6 + packages/experimental/README.md | 4 +- packages/experimental/index.d.ts | 11 +- packages/experimental/index.js | 31 +- packages/runtime/src/scr_itable.c | 308 +++++++++++++++--- packages/runtime/src/scr_itable.h | 15 +- packages/runtime/src/scr_itable.test.c | 70 ++++ packages/runtime/src/scr_worker.c | 2 + tests/corpus/5724-intern-table-array-keys.ts | 79 +++++ tests/corpus/5725-intern-table-callbacks.ts | 73 +++++ .../__snapshots__/intern-table-fences.ts.txt | 2 +- 18 files changed, 655 insertions(+), 80 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json create mode 100644 tests/corpus/5724-intern-table-array-keys.ts create mode 100644 tests/corpus/5725-intern-table-callbacks.ts diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index 4ee174fc6..68026072b 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -159,7 +159,7 @@ worker: square: 4 lines, 3 shapes
new InternTable<K, V>(name)
-
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings or numbers and compare like Map keys.
+
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like Map keys; arrays compare by their elements in the same way.
table.get(key), table.has(key), table.size
Read the table.
table.getOrInsert(key, value)
@@ -180,7 +180,7 @@ No lock is held while a callback runs. Two threads that miss the same key at the 2. It does not compare identities or ids across threads. 3. A thread does not depend on another thread's inserts: `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a module-level `Map` keyed by the value: module state is per thread in both runtimes. +State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a module-level `Map` keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. ### Memory diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2954d5c28..c1c9d1591 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -8672,8 +8672,9 @@ export class LlEmitter { return { name: handle, type: e.type }; }; const keyCall = (op: "find" | "insert"): string => { - const key = e.args[1]!.type.kind === "string" ? "str" : "f64"; - const keyTy = key === "str" ? "ptr" : "double"; + const kind = e.args[1]!.type.kind; + const key = kind === "string" ? "str" : kind === "array" ? "arr" : "f64"; + const keyTy = key === "f64" ? "double" : "ptr"; const value = op === "insert" ? ", i64" : ""; this.declare(`declare ptr @scr_itable_${op}_${key}(ptr, ${keyTy}${value})`); return `@scr_itable_${op}_${key}(ptr ${toPtr(args[0]!.name)}, ${keyTy} ${args[1]!.name}`; diff --git a/packages/compiler/src/backend/llvm/intern-tables.test.ts b/packages/compiler/src/backend/llvm/intern-tables.test.ts index d47326727..2c26c7a8f 100644 --- a/packages/compiler/src/backend/llvm/intern-tables.test.ts +++ b/packages/compiler/src/backend/llvm/intern-tables.test.ts @@ -42,6 +42,19 @@ test("InternTable's intrinsics call the runtime and read value slots directly", intrinsic("itable.value", [inserted], STRING), intrinsic("itable.size", [table], F64), intrinsic("itable.find", [intrinsic("itable.open", [str("n"), num(1)], F64), num(1)], F64), + intrinsic( + "itable.find", + [ + intrinsic("itable.open", [str("a"), num(2)], F64), + { + kind: "arrayLit", + elems: [num(1), num(2)], + type: { kind: "array", elem: F64 }, + loc, + } as IrExpr, + ], + F64, + ), ]; expect(validateModule(module(calls))).toEqual([]); const main = body(emitLlvmModule(module(calls), { pointerBits: 64 }), "sc_f_main"); @@ -50,6 +63,7 @@ test("InternTable's intrinsics call the runtime and read value slots directly", expect(main).toMatch(/call ptr @scr_itable_insert_str\(ptr %\S+, ptr @\S+, i64 %\S+\)/); expect(main).toMatch(/call ptr @scr_itable_find_f64\(ptr %\S+, double \S+\)/); expect(main).toMatch(/call double @scr_itable_size\(ptr %\S+\)/); + expect(main).toMatch(/call ptr @scr_itable_find_arr\(ptr %\S+, ptr %\S+\)/); // Entry handles reach their load through memory (x86 FastISel folds // inttoptr of a bitcast double into the address without moving it out // of its XMM register at -O0). @@ -68,7 +82,7 @@ test("the validator checks InternTable intrinsic operands", () => { ]; expect(validateModule(module(bad)).map((e) => e.message)).toEqual([ "in main: itable.open takes a string name and an f64 key kind", - "in main: itable.find key must be string or f64", + "in main: itable.find key must be string, f64, or an array of either", "in main: itable.insert takes 3 arguments", "in main: itable.size has type f64", ]); diff --git a/packages/compiler/src/frontend/lowering/intern-tables.ts b/packages/compiler/src/frontend/lowering/intern-tables.ts index 0c94257a5..522563355 100644 --- a/packages/compiler/src/frontend/lowering/intern-tables.ts +++ b/packages/compiler/src/frontend/lowering/intern-tables.ts @@ -18,7 +18,8 @@ * publication analysis guards stores into the value's types), and reads * the entry's value (itable.value). `getOrInsertComputed` with a * function expression lowers in place instead, and creates the callback - * only when the key has no entry. + * only when the key has no entry; an arrow function with an expression + * body is not created at all: its body is lowered in place. * The entry handles that itable.find and itable.insert answer stay inside * these lowerings: f64 locals that only reach toBool and itable.value. * Any other use of the class or its members as values is refused. */ @@ -41,8 +42,15 @@ const TABLE_FIELD = "#table"; const BUILTIN_LOC: SrcLoc = { file: "", start: 0, end: 0 }; -/** Runtime key kinds (scr_itable.h SCR_ITABLE_KEY_*). */ -const KEY_KIND: Record = { string: 0, f64: 1 }; +/** The runtime key kind of a key type (scr_itable.h SCR_ITABLE_KEY_*): + * strings, numbers, and arrays of numbers or of strings. */ +function keyKind(key: IrType): number | null { + if (key.kind === "string") return 0; + if (key.kind === "f64") return 1; + if (key.kind === "array" && key.elem.kind === "f64") return 2; + if (key.kind === "array" && key.elem.kind === "string") return 3; + return null; +} /** Value types a table stores: the publishable Map value types. */ const VALUE_KINDS = new Set([ @@ -138,12 +146,12 @@ function tableTypes(lowerer: Lowerer, type: ts.Type, blame: ts.Node): TableTypes const args = table ? lowerer.checker.getTypeArguments(table as ts.TypeReference) : []; if (args.length !== 2) lowerer.noLowering("InternTable without type arguments", blame); const key = lowerer.mapTypeOf(args[0]!); - if (key === null || (key.kind !== "string" && key.kind !== "f64")) + if (key === null || keyKind(key) === null) lowerer.unsupported( "SC1090", blame, `'${lowerer.checker.typeToString(args[0]!)}' as an InternTable key type`, - "use string or number keys", + "use string or number keys, or arrays of numbers or of strings", ); const value = lowerer.mapTypeOf(args[1]!); if (value === null || !VALUE_KINDS.has(value.kind)) @@ -189,7 +197,7 @@ export function lowerInternTableNew(lowerer: Lowerer, expr: ts.NewExpression): I className: INTERN_TABLE_CLASS, args: [ { kind: "strLit", value: name, type: STRING, loc }, - { kind: "numLit", value: KEY_KIND[types.key.kind]!, type: F64, loc }, + { kind: "numLit", value: keyKind(types.key)!, type: F64, loc }, ], type: internTableType(lowerer), loc, @@ -300,7 +308,9 @@ export function lowerInternTableCall(lowerer: Lowerer, expr: ts.CallExpression): * e ? itable.value(e) : itable.value(itable.insert(t.#table, k, publish(callback(k)))) * * Creating a function has no observable effect, so the callback's closure - * is created only when the key has no entry, and a hit allocates nothing. */ + * is created only when the key has no entry, and a hit allocates nothing. + * An arrow function with an expression body is lowered in place instead + * (inlineArrowBody). */ function lowerComputedInline( lowerer: Lowerer, table: IrExpr, @@ -316,10 +326,14 @@ function lowerComputedInline( const entryLocal = lowerer.declareHiddenLocal("%internEntry", F64); const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc }); const handle = (): IrExpr => handleOf(ref(tableLocal.id, tableT), loc); - const callback = lowerer.coerceInto(callbackNode, lowerer.lowerExpr(callbackNode), callbackType); - const computed: IrExpr = { + const computed: IrExpr = inlineArrowBody( + lowerer, + callbackNode, + ref(keyLocal.id, types.key), + types.value, + ) ?? { kind: "callValue", - callee: callback, + callee: lowerer.coerceInto(callbackNode, lowerer.lowerExpr(callbackNode), callbackType), args: [ref(keyLocal.id, types.key)], type: types.value, loc, @@ -367,6 +381,64 @@ function lowerComputedInline( }; } +/** Whether `node` contains a function or class, whose captures of the + * inlined parameter would need a closure after all. */ +function containsFunction(node: ts.Node): boolean { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return true; + return ts.forEachChild(node, (child) => (containsFunction(child) ? true : undefined)) === true; +} + +/** The body of `() => value` or `(k) => value`, lowered where the callback + * would be called, with the parameter bound to the key. Calling a fresh + * arrow function at once is evaluating its body there with its parameter + * bound, so no closure is created. Other callbacks answer null. */ +function inlineArrowBody( + lowerer: Lowerer, + node: ts.Expression, + key: IrExpr, + value: IrType, +): IrExpr | null { + if (!ts.isArrowFunction(node) || ts.isBlock(node.body)) return null; + if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return null; + const param = node.parameters[0]; + if ( + node.parameters.length > 1 || + (param !== undefined && + (!ts.isIdentifier(param.name) || + param.initializer !== undefined || + param.dotDotDotToken !== undefined)) + ) + return null; + if (containsFunction(node.body)) return null; + const body = node.body; + const loc = locOf(node); + lowerer.scopes.push(new Map()); + try { + const stmts: IrStmt[] = []; + if (param !== undefined) { + const paramTs = lowerer.typeOf(param); + const paramType = lowerer.mapTypeOf(paramTs); + if (paramType === null) lowerer.badType(param, paramTs); + const local = lowerer.declareLocal( + param.name, + (param.name as ts.Identifier).text, + paramType!, + false, + ); + stmts.push({ + kind: "varDecl", + localId: local.id, + init: lowerer.coerceToExpected(key, paramType!), + loc, + }); + } + const result = lowerer.coerceInto(body, lowerer.lowerExpr(body), value); + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: value, loc }; + } finally { + lowerer.scopes.pop(); + } +} + function helperName(lowerer: Lowerer, member: InternTableMember, signature: string): string | null { return lowerer.internTableHelpers.get(`${member}|${signature}`) ?? null; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 64771a0bf..b45a36ed7 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3481,7 +3481,8 @@ export type IrExpr = * has no entry; itable.insert (f64 table, K key, V value) → f64 the * key's entry, storing the already published value when the key has * none; itable.value (f64 entry) → V, the entry's immortal value; - * itable.size (f64 table) → f64. K is string or f64. Handles carry + * itable.size (f64 table) → f64. K is string, f64, or an array of + * f64 or of strings (compared by elements). Handles carry * raw pointer bits and only reach toBool and these intrinsics. * None of them throws. */ | "itable.open" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index b71f6645a..129e175bc 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3819,9 +3819,13 @@ function validateFunction( else if ( (e.name === "itable.find" || e.name === "itable.insert") && key!.type.kind !== "string" && - key!.type.kind !== "f64" + key!.type.kind !== "f64" && + !( + key!.type.kind === "array" && + (key!.type.elem.kind === "f64" || key!.type.elem.kind === "string") + ) ) - err(`${e.name} key must be string or f64`, e.loc); + err(`${e.name} key must be string, f64, or an array of either`, e.loc); else if (e.name === "itable.insert" && !isSupportedMapValue(value!.type)) err(`itable.insert value ${typeKey(value!.type)} is not a table value type`, e.loc); if (e.name === "itable.value") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json new file mode 100644 index 000000000..624195e07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5724-intern-table-array-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json new file mode 100644 index 000000000..87857311f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5725-intern-table-callbacks.ts" + ], + "diags": [] +} diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 3b22148b8..5dc9775df 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -55,7 +55,7 @@ function unionOf(members: readonly Type[]): Type { ``` - `new InternTable(name)` opens the table called `name`. Within one thread, every construction with the same name refers to the same table. In scriptc builds the table is shared by every thread of the process; under Node.js each thread has its own. -- Keys are strings or numbers and compare like `Map` keys: strings by content, numbers by SameValueZero. +- Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like `Map` keys: strings by content, numbers by SameValueZero. Arrays compare by their elements in the same way, so a freshly built `[a.id, b.id]` finds the value stored under an equal array; holes are equal to each other and to no number or string. - `get(key)`, `has(key)` and `size` read the table. - `getOrInsert(key, value)` returns the value stored under `key`. When there is none, it publishes `value`, stores it and returns it. - `getOrInsertComputed(key, callback)` returns the value stored under `key`. When there is none, it calls `callback(key)`, publishes the result and stores it, unless a value was stored under `key` meanwhile (by the callback, or in scriptc builds by another thread), and returns the stored value. @@ -70,6 +70,6 @@ Programs that behave identically under scriptc and Node.js follow the `@scriptc/ 2. Identity and ids are not compared across threads. Ids assigned inside callbacks are unique in both runtimes but differ between them. 3. A thread does not depend on another thread's inserts. `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a module-level `Map` keyed by the value: module state is per thread in both runtimes. +Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a module-level `Map` keyed by the value, or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. In scriptc builds stored values are immortal: they stay allocated until the process exits, including values a callback computed for a key that another thread stored first. diff --git a/packages/experimental/index.d.ts b/packages/experimental/index.d.ts index f072fe438..e5079112a 100644 --- a/packages/experimental/index.d.ts +++ b/packages/experimental/index.d.ts @@ -24,13 +24,18 @@ export declare class LifetimeRegion { * publishes it, so stored values and everything they reach are immutable. * scriptc requires the name to be a string literal and every construction * of one name to use the same type arguments. */ -export declare class InternTable { +export declare class InternTable< + K extends string | number | readonly number[] | readonly string[], + V, +> { /** Opens the table called `name`. */ constructor(name: string); /** The number of entries. */ readonly size: number; - /** The value stored under `key`, or undefined. Keys compare like Map - * keys: strings by content, numbers by SameValueZero. */ + /** The value stored under `key`, or undefined. String and number keys + * compare like Map keys: strings by content, numbers by SameValueZero. + * Array keys compare by their elements in the same way; a hole and an + * undefined element are equal. */ get(key: K): V | undefined; /** Whether `key` has a value. */ has(key: K): boolean; diff --git a/packages/experimental/index.js b/packages/experimental/index.js index 5f1fe64fb..6c4af68ba 100644 --- a/packages/experimental/index.js +++ b/packages/experimental/index.js @@ -22,6 +22,21 @@ export class LifetimeRegion { /** This thread's tables, by name. */ const tables = new Map(); +/** The Map key of a table key: strings and numbers as they are, arrays as a + * string that encodes each element (numbers by SameValueZero, strings by + * content, holes and undefined elements alike). */ +function keyOf(key) { + if (!Array.isArray(key)) return key; + let text = ""; + for (let i = 0; i < key.length; i++) { + const element = key[i]; + if (typeof element === "number") text += `n${Object.is(element, -0) ? 0 : element};`; + else if (typeof element === "string") text += `s${element.length}:${element}`; + else text += "u;"; + } + return text; +} + export class InternTable { #entries; @@ -40,21 +55,22 @@ export class InternTable { /** The value stored under `key`, or undefined. */ get(key) { - return this.#entries.get(key); + return this.#entries.get(keyOf(key)); } /** Whether `key` has a value. */ has(key) { - return this.#entries.has(key); + return this.#entries.has(keyOf(key)); } /** The value stored under `key`; when there is none, publishes `value`, * stores it and returns it. */ getOrInsert(key, value) { const entries = this.#entries; - if (entries.has(key)) return entries.get(key); + const k = keyOf(key); + if (entries.has(k)) return entries.get(k); publish(value); - entries.set(key, value); + entries.set(k, value); return value; } @@ -63,10 +79,11 @@ export class InternTable { * `key` itself. Returns the stored value. */ getOrInsertComputed(key, callback) { const entries = this.#entries; - if (entries.has(key)) return entries.get(key); + const k = keyOf(key); + if (entries.has(k)) return entries.get(k); const value = publish(callback(key)); - if (entries.has(key)) return entries.get(key); - entries.set(key, value); + if (entries.has(k)) return entries.get(k); + entries.set(k, value); return value; } } diff --git a/packages/runtime/src/scr_itable.c b/packages/runtime/src/scr_itable.c index bd67d8e9e..dcdcf481c 100644 --- a/packages/runtime/src/scr_itable.c +++ b/packages/runtime/src/scr_itable.c @@ -5,9 +5,12 @@ * key's 64-bit hash pick the segment and the low bits the first slot of a * linear probe in the segment's index. Number tables keep each key and its * value in the index slot itself, so a lookup touches one slot per probe. - * String tables keep entries (value, hash and key bytes) in chunks owned by - * the segment; their slots hold the entry pointer with a tag of hash bits - * 50..57 in bits 48..55, so a probe reads an entry only when the tags match. + * String and array tables keep entries (value, hash and key bytes) in + * chunks owned by the segment; their slots hold the entry pointer with a + * tag of hash bits 50..57 in bits 48..55, so a probe reads an entry only + * when the tags match. An array key is stored as one byte string that + * encodes its elements (scr_itable_encode), so array keys compare by + * their elements. * * Readers load the segment's index pointer, then slots with acquire order, * and never write. Writers hold the segment's lock: they probe again, grow @@ -15,13 +18,12 @@ * value (and the entry), and store the slot's last word with release order. * A grown index is complete before its pointer is stored with release * order, and a reader that still holds the previous one sees every key - * stored before the growth. Replaced indexes are kept on the segment's - * retired list, because readers may still be probing them (and number - * handles point into them); their total size stays below the current - * index's. + * stored before the growth. A replaced index is retired and freed once no + * thread can still be probing it (see "retired indexes" below). * - * Nothing here is freed. Every allocation is reachable from the static - * registry, so leak checkers report none of it. */ + * Tables, their entries and their current indexes are never freed. Every + * such allocation is reachable from the static registry, so leak checkers + * report none of it. */ #include "scr_runtime.h" #include "scr_itable.h" @@ -50,17 +52,22 @@ #define SCR_ITABLE_TAG_MASK ((uint64_t)0xff << SCR_ITABLE_TAG_SHIFT) /* Number slots are { value, key }, with the key stored as its normalized - * bits XOR this NaN pattern, which normalization never produces: a zero - * key word is an empty slot. */ + * bits XOR this NaN pattern, which normalization never produces (so no + * stored key is 0), and a tag byte of hash bits 48..55 (0 mapped to 1). */ #define SCR_ITABLE_F64_FLIP UINT64_C(0x7ff4000000000001) typedef struct ScrITableIndex { uint64_t mask; /* slots - 1 */ - struct ScrITableIndex *retired; /* the index this one replaced */ + struct ScrITableIndex *retired; /* the retired list's next index */ + uint64_t epoch; /* the epoch it was retired in */ + /* Number indexes: one tag byte per slot (0: empty), after the words. A + * probe reads the dense tags first, so a miss rarely touches a pair. */ + _Atomic uint8_t *tags; _Atomic uint64_t words[]; /* one word per string slot, two per number slot */ } ScrITableIndex; -/* A string table entry: the value slot first (scr_itable.h). */ +/* A string or array table entry: the value slot first (scr_itable.h), + * then the key's bytes. */ typedef struct { uint64_t value; uint64_t hash; @@ -125,6 +132,92 @@ static void scr_itable_unlock(atomic_uint *lock) { atomic_store_explicit(lock, 0, memory_order_release); } +/* ── retired indexes ───────────────────────────────────────────────── + * A thread that loaded a segment's index pointer before the index grew may + * still be probing the old index (or about to read a number entry handle + * that points into it). Every thread that uses a table has a reader record: + * at the start of each lookup and insert it records the global epoch it + * reads there, with release order. Growth publishes the new index, retires + * the old one under the current epoch and advances the epoch. A retired + * index is freed once every registered reader has recorded a later epoch: + * such a reader began its current operation after the growth, so it reads + * the new pointer, and the operations before, which may have read the old + * one, are complete. Entry handles are used before the thread's next lookup + * or insert, so the same rule covers them. A thread that stops using tables + * keeps its last epoch, and with it later retirements, until it uses a + * table again or exits (scr_itable_thread_exit). */ +typedef struct ScrITableReader { + _Atomic uint64_t seen; /* SCR_ITABLE_IDLE: the record is unused */ + struct ScrITableReader *next; +} ScrITableReader; + +#define SCR_ITABLE_IDLE UINT64_MAX + +static _Atomic uint64_t scr_itable_epoch = 1; +static ScrITableReader *_Atomic scr_itable_readers; +static SCR_TL ScrITableReader *scr_itable_reader; +static atomic_uint scr_itable_retired_lock; +static ScrITableIndex *scr_itable_retired; /* newest first */ + +static ScrITableReader *scr_itable_reader_new(void) { + /* Reuse the record of an exited thread, else add one. */ + for (ScrITableReader *r = atomic_load_explicit(&scr_itable_readers, memory_order_acquire); r; r = r->next) { + uint64_t idle = SCR_ITABLE_IDLE; + if (atomic_compare_exchange_strong_explicit(&r->seen, &idle, 0, memory_order_acq_rel, memory_order_relaxed)) + return r; + } + ScrITableReader *r = calloc(1, sizeof *r); + if (!r) scr_itable_oom(); + r->next = atomic_load_explicit(&scr_itable_readers, memory_order_relaxed); + while (!atomic_compare_exchange_weak_explicit(&scr_itable_readers, &r->next, r, memory_order_release, + memory_order_relaxed)) { + } + return r; +} + +/* The start of a lookup or insert: the calling thread holds nothing it + * read before. */ +static inline void scr_itable_enter(void) { + ScrITableReader *r = scr_itable_reader; + if (__builtin_expect(r == NULL, 0)) r = scr_itable_reader = scr_itable_reader_new(); + uint64_t epoch = atomic_load_explicit(&scr_itable_epoch, memory_order_acquire); + if (atomic_load_explicit(&r->seen, memory_order_relaxed) != epoch) + atomic_store_explicit(&r->seen, epoch, memory_order_release); +} + +void scr_itable_thread_exit(void) { + ScrITableReader *r = scr_itable_reader; + if (!r) return; + scr_itable_reader = NULL; + atomic_store_explicit(&r->seen, SCR_ITABLE_IDLE, memory_order_release); +} + +/* Retires `index` (the caller has published its replacement) and frees + * the retired indexes no reader can still hold. */ +static void scr_itable_retire(ScrITableIndex *index) { + scr_itable_lock(&scr_itable_retired_lock); + index->epoch = atomic_fetch_add_explicit(&scr_itable_epoch, 1, memory_order_acq_rel); + index->retired = scr_itable_retired; + scr_itable_retired = index; + uint64_t oldest = SCR_ITABLE_IDLE; + for (ScrITableReader *r = atomic_load_explicit(&scr_itable_readers, memory_order_acquire); r; r = r->next) { + uint64_t seen = atomic_load_explicit(&r->seen, memory_order_acquire); + if (seen < oldest) oldest = seen; + } + /* Free every index retired before the oldest recorded epoch. */ + ScrITableIndex **link = &scr_itable_retired; + while (*link) { + ScrITableIndex *old = *link; + if (old->epoch < oldest) { + *link = old->retired; + free(old); + } else { + link = &old->retired; + } + } + scr_itable_unlock(&scr_itable_retired_lock); +} + /* ── keys and hashes ─────────────────────────────────────────────────── */ static inline uint64_t scr_itable_f64_bits(double k) { @@ -161,6 +254,11 @@ static inline ScrITableSegment *scr_itable_segment(ScrITable *t, uint64_t hash) return &t->segments[hash >> (64 - SCR_ITABLE_SEGMENT_BITS)]; } +static inline uint8_t scr_itable_tag8(uint64_t hash) { + uint8_t tag = (uint8_t)(hash >> 48); + return tag ? tag : 1; +} + static inline uint64_t scr_itable_tag(uint64_t hash) { return ((hash >> 50) & 0xff) << SCR_ITABLE_TAG_SHIFT; } @@ -171,8 +269,8 @@ static inline const ScrITableStrEntry *scr_itable_slot_entry(uint64_t slot) { /* ── lookups ─────────────────────────────────────────────────────────── */ -static const ScrITableStrEntry *scr_itable_probe_str(const ScrITableIndex *index, uint64_t hash, - const ScrStr *key) { +static const ScrITableStrEntry *scr_itable_probe_bytes(const ScrITableIndex *index, uint64_t hash, + const char *bytes, size_t len) { if (!index) return NULL; uint64_t tag = scr_itable_tag(hash); for (uint64_t i = hash & index->mask;; i = (i + 1) & index->mask) { @@ -180,28 +278,32 @@ static const ScrITableStrEntry *scr_itable_probe_str(const ScrITableIndex *index if (slot == 0) return NULL; if ((slot & SCR_ITABLE_TAG_MASK) != tag) continue; const ScrITableStrEntry *e = scr_itable_slot_entry(slot); - if (e->hash == hash && e->len == key->len && scr_key_equal(e->data, key->data, key->len)) return e; + if (e->hash == hash && e->len == len && scr_key_equal(e->data, bytes, len)) return e; } } /* The { value, key } pair of a stored number key, or NULL. */ static _Atomic uint64_t *scr_itable_probe_f64(ScrITableIndex *index, uint64_t hash, uint64_t stored) { if (!index) return NULL; + uint8_t tag = scr_itable_tag8(hash); for (uint64_t i = hash & index->mask;; i = (i + 1) & index->mask) { - uint64_t key = atomic_load_explicit(&index->words[2 * i + 1], memory_order_acquire); - if (key == stored) return &index->words[2 * i]; - if (key == 0) return NULL; + uint8_t seen = atomic_load_explicit(&index->tags[i], memory_order_acquire); + if (seen == 0) return NULL; + if (seen == tag && atomic_load_explicit(&index->words[2 * i + 1], memory_order_relaxed) == stored) + return &index->words[2 * i]; } } const ScrITableEntry *scr_itable_find_str(ScrITable *t, const ScrStr *key) { + scr_itable_enter(); uint64_t hash = scr_itable_str_hash(key); ScrITableSegment *seg = scr_itable_segment(t, hash); - return (const ScrITableEntry *)scr_itable_probe_str( - atomic_load_explicit(&seg->index, memory_order_acquire), hash, key); + return (const ScrITableEntry *)scr_itable_probe_bytes( + atomic_load_explicit(&seg->index, memory_order_acquire), hash, key->data, key->len); } const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key) { + scr_itable_enter(); uint64_t stored = scr_itable_f64_bits(key) ^ SCR_ITABLE_F64_FLIP; uint64_t hash = scr_itable_f64_hash(stored); ScrITableSegment *seg = scr_itable_segment(t, hash); @@ -211,46 +313,55 @@ const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key) { /* ── inserts (the segment lock held) ─────────────────────────────────── */ -static ScrITableIndex *scr_itable_index_new(uint64_t slots, unsigned words) { - ScrITableIndex *index = calloc(1, sizeof(ScrITableIndex) + slots * words * sizeof(uint64_t)); +static ScrITableIndex *scr_itable_index_new(uint64_t slots, bool numbers) { + size_t words = slots * (numbers ? 2 : 1) * sizeof(uint64_t); + ScrITableIndex *index = calloc(1, sizeof(ScrITableIndex) + words + (numbers ? slots : 0)); if (!index) scr_itable_oom(); index->mask = slots - 1; + if (numbers) index->tags = (_Atomic uint8_t *)((char *)index->words + words); return index; } +/* Whether slot `i` holds a key (the caller holds the segment lock). */ +static inline bool scr_itable_used(const ScrITableIndex *index, uint64_t i, bool numbers) { + if (numbers) return atomic_load_explicit(&index->tags[i], memory_order_relaxed) != 0; + return atomic_load_explicit(&index->words[i], memory_order_relaxed) != 0; +} + /* Makes room for one more key: the segment's index, created or grown so * that it stays at most half full. Growth rehashes every stored key. */ static ScrITableIndex *scr_itable_reserve(ScrITableSegment *seg, bool numbers) { ScrITableIndex *index = atomic_load_explicit(&seg->index, memory_order_relaxed); uint64_t count = atomic_load_explicit(&seg->count, memory_order_relaxed); - unsigned words = numbers ? 2 : 1; if (index && (count + 1) * 2 <= index->mask + 1) return index; - ScrITableIndex *grown = scr_itable_index_new(index ? (index->mask + 1) * 2 : SCR_ITABLE_FIRST_SLOTS, words); + ScrITableIndex *grown = scr_itable_index_new(index ? (index->mask + 1) * 2 : SCR_ITABLE_FIRST_SLOTS, numbers); if (index) { for (uint64_t i = 0; i <= index->mask; i++) { - uint64_t last = atomic_load_explicit(&index->words[i * words + words - 1], memory_order_relaxed); - if (last == 0) continue; + if (!scr_itable_used(index, i, numbers)) continue; + uint64_t last = atomic_load_explicit(&index->words[numbers ? 2 * i + 1 : i], memory_order_relaxed); uint64_t hash = numbers ? scr_itable_f64_hash(last) : scr_itable_slot_entry(last)->hash; uint64_t j = hash & grown->mask; - while (atomic_load_explicit(&grown->words[j * words + words - 1], memory_order_relaxed) != 0) - j = (j + 1) & grown->mask; - if (numbers) + while (scr_itable_used(grown, j, numbers)) j = (j + 1) & grown->mask; + if (numbers) { atomic_store_explicit(&grown->words[2 * j], atomic_load_explicit(&index->words[2 * i], memory_order_relaxed), memory_order_relaxed); - atomic_store_explicit(&grown->words[j * words + words - 1], last, memory_order_relaxed); + atomic_store_explicit(&grown->words[2 * j + 1], last, memory_order_relaxed); + atomic_store_explicit(&grown->tags[j], scr_itable_tag8(hash), memory_order_relaxed); + } else { + atomic_store_explicit(&grown->words[j], last, memory_order_relaxed); + } } - grown->retired = index; } atomic_store_explicit(&seg->index, grown, memory_order_release); + if (index) scr_itable_retire(index); return grown; } /* The first empty slot of the key's probe (the key is known to be absent). */ -static uint64_t scr_itable_free_slot(const ScrITableIndex *index, uint64_t hash, unsigned words) { +static uint64_t scr_itable_free_slot(const ScrITableIndex *index, uint64_t hash, bool numbers) { uint64_t i = hash & index->mask; - while (atomic_load_explicit(&index->words[i * words + words - 1], memory_order_relaxed) != 0) - i = (i + 1) & index->mask; + while (scr_itable_used(index, i, numbers)) i = (i + 1) & index->mask; return i; } @@ -279,21 +390,23 @@ static void *scr_itable_alloc(ScrITableSegment *seg, size_t size) { return p; } -const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value) { - if (key->len > UINT32_MAX) scr_trap("scriptc: InternTable key is too long\n"); - uint64_t hash = scr_itable_str_hash(key); +/* Stores `value` under a byte key (a string's bytes or an encoded array) + * unless the key has an entry. */ +static const ScrITableEntry *scr_itable_insert_bytes(ScrITable *t, uint64_t hash, const char *bytes, + size_t len, uint64_t value) { + if (len > UINT32_MAX) scr_trap("scriptc: InternTable key is too long\n"); ScrITableSegment *seg = scr_itable_segment(t, hash); scr_itable_lock(&seg->lock); const ScrITableStrEntry *found = - scr_itable_probe_str(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, key); + scr_itable_probe_bytes(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, bytes, len); if (!found) { ScrITableIndex *index = scr_itable_reserve(seg, false); - ScrITableStrEntry *entry = scr_itable_alloc(seg, sizeof(ScrITableStrEntry) + key->len); + ScrITableStrEntry *entry = scr_itable_alloc(seg, sizeof(ScrITableStrEntry) + len); entry->value = value; entry->hash = hash; - entry->len = (uint32_t)key->len; - memcpy(entry->data, key->data, key->len); - uint64_t i = scr_itable_free_slot(index, hash, 1); + entry->len = (uint32_t)len; + memcpy(entry->data, bytes, len); + uint64_t i = scr_itable_free_slot(index, hash, false); atomic_store_explicit(&index->words[i], (uint64_t)(uintptr_t)entry | scr_itable_tag(hash), memory_order_release); scr_itable_counted(seg); @@ -303,7 +416,13 @@ const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uin return (const ScrITableEntry *)found; } +const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value) { + scr_itable_enter(); + return scr_itable_insert_bytes(t, scr_itable_str_hash(key), key->data, key->len, value); +} + const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t value) { + scr_itable_enter(); uint64_t stored = scr_itable_f64_bits(key) ^ SCR_ITABLE_F64_FLIP; uint64_t hash = scr_itable_f64_hash(stored); ScrITableSegment *seg = scr_itable_segment(t, hash); @@ -312,9 +431,10 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v scr_itable_probe_f64(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, stored); if (!found) { ScrITableIndex *index = scr_itable_reserve(seg, true); - uint64_t i = scr_itable_free_slot(index, hash, 2); + uint64_t i = scr_itable_free_slot(index, hash, true); atomic_store_explicit(&index->words[2 * i], value, memory_order_relaxed); - atomic_store_explicit(&index->words[2 * i + 1], stored, memory_order_release); + atomic_store_explicit(&index->words[2 * i + 1], stored, memory_order_relaxed); + atomic_store_explicit(&index->tags[i], scr_itable_tag8(hash), memory_order_release); scr_itable_counted(seg); found = &index->words[2 * i]; } @@ -322,6 +442,104 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v return (const ScrITableEntry *)found; } +/* ── array keys ──────────────────────────────────────────────────────── */ + +/* The state and slot of element `i` of `a` (i < a->len). */ +static ScrArrState scr_itable_element(const ScrArr *a, size_t i, uint64_t *slot) { + if (i < a->filled) { + *slot = a->data[i]; + return SCR_ARR_VALUE; + } + if (i < a->cap) { + *slot = a->data[i]; + return (ScrArrState)a->present[i]; + } + if (a->ext) + for (size_t j = 0; j < a->ext->sparse_len; j++) + if (a->ext->sparse[j].index == i) { + *slot = a->ext->sparse[j].slot; + return (ScrArrState)a->ext->sparse[j].state; + } + return SCR_ARR_HOLE; +} + +/* An array key as bytes: per number element its normalized bits, per + * string element a 32-bit length and the bytes; a hole or undefined + * element is the bits of a NaN that normalization never produces, or the + * length 0xffffffff. Answers the encoded length; writes at most `cap` + * bytes into `out`. */ +#define SCR_ITABLE_ABSENT_F64 UINT64_C(0x7ff4000000000002) +#define SCR_ITABLE_ABSENT_STR UINT32_C(0xffffffff) + +static size_t scr_itable_encode(const ScrArr *key, int kind, char *out, size_t cap) { + size_t n = 0; + for (size_t i = 0; i < key->len; i++) { + uint64_t slot = 0; + bool present = scr_itable_element(key, i, &slot) == SCR_ARR_VALUE; + if (kind == SCR_ITABLE_KEY_F64S) { + uint64_t bits = SCR_ITABLE_ABSENT_F64; + if (present) { + double d; + memcpy(&d, &slot, sizeof d); + bits = scr_itable_f64_bits(d); + } + if (n + 8 <= cap) memcpy(out + n, &bits, 8); + n += 8; + } else { + const ScrStr *s = present ? (const ScrStr *)(uintptr_t)slot : NULL; + if (s && s->len >= SCR_ITABLE_ABSENT_STR) scr_trap("scriptc: InternTable key is too long\n"); + uint32_t len = s ? (uint32_t)s->len : SCR_ITABLE_ABSENT_STR; + if (n + 4 <= cap) memcpy(out + n, &len, 4); + n += 4; + if (s) { + if (n + s->len <= cap) memcpy(out + n, s->data, s->len); + n += s->len; + } + } + } + return n; +} + +static inline uint64_t scr_itable_bytes_hash(const char *bytes, size_t len) { + return scr_key_mix(scr_key_hash(bytes, len) ^ ((uint64_t)len << 1)); +} + +/* Encodes `key` into `stack` when it fits, else into a heap buffer the + * caller frees. */ +static char *scr_itable_encoded(const ScrITable *t, const ScrArr *key, char *stack, size_t cap, size_t *len) { + *len = scr_itable_encode(key, t->key_kind, stack, cap); + if (*len <= cap) return stack; + char *heap = malloc(*len); + if (!heap) scr_itable_oom(); + scr_itable_encode(key, t->key_kind, heap, *len); + return heap; +} + +#define SCR_ITABLE_STACK_KEY 256 + +const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key) { + scr_itable_enter(); + char stack[SCR_ITABLE_STACK_KEY]; + size_t len; + char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + uint64_t hash = scr_itable_bytes_hash(bytes, len); + ScrITableSegment *seg = scr_itable_segment(t, hash); + const ScrITableEntry *found = (const ScrITableEntry *)scr_itable_probe_bytes( + atomic_load_explicit(&seg->index, memory_order_acquire), hash, bytes, len); + if (bytes != stack) free(bytes); + return found; +} + +const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uint64_t value) { + scr_itable_enter(); + char stack[SCR_ITABLE_STACK_KEY]; + size_t len; + char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + const ScrITableEntry *entry = scr_itable_insert_bytes(t, scr_itable_bytes_hash(bytes, len), bytes, len, value); + if (bytes != stack) free(bytes); + return entry; +} + double scr_itable_size(const ScrITable *t) { uint64_t total = 0; for (unsigned i = 0; i < SCR_ITABLE_SEGMENTS; i++) diff --git a/packages/runtime/src/scr_itable.h b/packages/runtime/src/scr_itable.h index f0a066124..9ea3258dd 100644 --- a/packages/runtime/src/scr_itable.h +++ b/packages/runtime/src/scr_itable.h @@ -3,7 +3,7 @@ #ifndef SCR_ITABLE_H #define SCR_ITABLE_H -#include "scr_string.h" +#include "scr_array.h" /* ── shared intern tables (scr_itable.c) ────────────────────────────── * InternTable (@scriptc/experimental): named, append-only tables of @@ -23,7 +23,10 @@ * which emitted code reads directly. A handle stays valid, and its slot * unchanged, for the life of the process. */ -enum { SCR_ITABLE_KEY_STR = 0, SCR_ITABLE_KEY_F64 = 1 }; +/* Key kinds: strings, numbers, and arrays of numbers or of strings, which + * compare by their elements (holes and undefined elements are equal to + * each other and to nothing else). */ +enum { SCR_ITABLE_KEY_STR = 0, SCR_ITABLE_KEY_F64 = 1, SCR_ITABLE_KEY_F64S = 2, SCR_ITABLE_KEY_STRS = 3 }; typedef struct ScrITable ScrITable; @@ -38,13 +41,17 @@ ScrITable *scr_itable_open(const ScrStr *name, int key_kind); /* The entry of `key`, or NULL. */ const ScrITableEntry *scr_itable_find_str(ScrITable *t, const ScrStr *key); const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key); +const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key); /* Stores `value` under `key` unless the key has an entry already; answers * the key's entry either way. `value` must already be immortal (or a - * scalar): the table neither retains nor releases it. String keys are - * copied. */ + * scalar): the table neither retains nor releases it. Keys are copied. */ const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value); const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t value); +const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uint64_t value); /* The number of entries. */ double scr_itable_size(const ScrITable *t); +/* The calling thread uses no table again (worker exit): replaced indexes + * no longer wait for it. */ +void scr_itable_thread_exit(void); #endif /* SCR_ITABLE_H */ diff --git a/packages/runtime/src/scr_itable.test.c b/packages/runtime/src/scr_itable.test.c index 638d6c877..7781d4d01 100644 --- a/packages/runtime/src/scr_itable.test.c +++ b/packages/runtime/src/scr_itable.test.c @@ -34,6 +34,29 @@ static ScrStr *make_str(const char *text) { return s; } +static ScrArr *make_f64s(const double *values, size_t n) { + ScrArr *a = calloc(1, sizeof(ScrArr)); + assert(a); + a->rc = 1; + a->len = a->cap = n; + a->filled = (uint32_t)n; + a->elem = SCR_ELEM_F64; + a->data = calloc(n ? n : 1, sizeof(uint64_t)); + for (size_t i = 0; i < n; i++) memcpy(&a->data[i], &values[i], sizeof(double)); + return a; +} + +static ScrArr *make_strs(ScrStr *const *values, size_t n) { + ScrArr *a = make_f64s(NULL, 0); + a->len = a->cap = n; + a->filled = (uint32_t)n; + a->elem = SCR_ELEM_STR; + free(a->data); + a->data = calloc(n, sizeof(uint64_t)); + for (size_t i = 0; i < n; i++) a->data[i] = (uint64_t)(uintptr_t)values[i]; + return a; +} + static void key_text(char *out, size_t size, uint64_t k) { snprintf(out, size, "key-%llu", (unsigned long long)k); } static ScrITable *strings, *numbers; @@ -81,6 +104,7 @@ static void *run(void *arg) { assert(!scr_itable_find_f64(numbers, (double)KEYS + 0.5 + n)); } } + scr_itable_thread_exit(); return NULL; } @@ -111,6 +135,52 @@ int main(void) { free(other); free(name); + /* Array keys compare by their elements: numbers by SameValueZero, + * strings by content, holes like undefined elements. */ + ScrStr *lists_name = make_str("lists"), *paths_name = make_str("paths"); + ScrITable *lists = scr_itable_open(lists_name, SCR_ITABLE_KEY_F64S); + ScrArr *one = make_f64s((double[]){1, -0.0, 0.0 / 0.0}, 3); + ScrArr *same = make_f64s((double[]){1, 0.0, -(0.0 / 0.0)}, 3); + ScrArr *shorter = make_f64s((double[]){1, 0.0}, 2); + ScrArr *holed = make_f64s((double[]){1, 0.0, 0.0}, 3); + holed->filled = 1; + holed->present = calloc(3, 1); + holed->present[0] = SCR_ARR_VALUE; + holed->present[1] = SCR_ARR_VALUE; + holed->present[2] = SCR_ARR_HOLE; + ScrArr *empty_list = make_f64s(NULL, 0); + const ScrITableEntry *l1 = scr_itable_insert_arr(lists, one, 1); + assert(scr_itable_find_arr(lists, same) == l1 && scr_itable_insert_arr(lists, same, 2) == l1); + assert(!scr_itable_find_arr(lists, shorter) && !scr_itable_find_arr(lists, holed)); + assert(scr_itable_insert_arr(lists, holed, 3)->value == 3 && scr_itable_insert_arr(lists, shorter, 4)->value == 4); + assert(scr_itable_insert_arr(lists, empty_list, 5)->value == 5 && scr_itable_find_arr(lists, empty_list)->value == 5); + assert(scr_itable_size(lists) == 4); + ScrArr *big = make_f64s(NULL, 0); + big->len = big->cap = big->filled = 100; + free(big->data); + big->data = calloc(100, sizeof(uint64_t)); + for (unsigned i = 0; i < 100; i++) memcpy(&big->data[i], &(double){i}, sizeof(double)); + assert(scr_itable_insert_arr(lists, big, 6)->value == 6 && scr_itable_find_arr(lists, big)->value == 6); + ScrITable *paths = scr_itable_open(paths_name, SCR_ITABLE_KEY_STRS); + ScrStr *ab = make_str("ab"), *c = make_str("c"), *a = make_str("a"), *bc = make_str("bc"); + ScrArr *p1 = make_strs((ScrStr *[]){ab, c}, 2), *p2 = make_strs((ScrStr *[]){a, bc}, 2); + ScrArr *p3 = make_strs((ScrStr *[]){ab, c}, 2); + const ScrITableEntry *e1 = scr_itable_insert_arr(paths, p1, 7); + assert(scr_itable_insert_arr(paths, p2, 8) != e1 && scr_itable_find_arr(paths, p3) == e1); + assert(scr_itable_size(paths) == 2); + ScrArr *arrays[] = {one, same, shorter, holed, empty_list, big, p1, p2, p3}; + for (unsigned i = 0; i < sizeof arrays / sizeof *arrays; i++) { + free(arrays[i]->data); + free(arrays[i]->present); + free(arrays[i]); + } + free(ab); + free(c); + free(a); + free(bc); + free(lists_name); + free(paths_name); + pthread_t threads[THREADS]; for (unsigned i = 0; i < THREADS; i++) assert(pthread_create(&threads[i], NULL, run, (void *)(uintptr_t)i) == 0); diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 7245c5349..4f42f9bfe 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -228,6 +228,8 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { * only once none of those operations can touch the worker's script heap. */ scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_EXIT, worker->id, code, NULL)); scr_worker_thread_release(worker); + /* Replaced InternTable indexes no longer wait for this thread. */ + scr_itable_thread_exit(); /* The next script thread continues this thread's allocator arena. */ scr_sa_thread_park(); } diff --git a/tests/corpus/5724-intern-table-array-keys.ts b/tests/corpus/5724-intern-table-array-keys.ts new file mode 100644 index 000000000..f36cc860a --- /dev/null +++ b/tests/corpus/5724-intern-table-array-keys.ts @@ -0,0 +1,79 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { InternTable } from "@scriptc/experimental"; + +// InternTable (@scriptc/experimental) with array keys: arrays of numbers or +// of strings compare by their elements (numbers by SameValueZero, strings +// by content), so a freshly built key finds the value stored under an equal +// one. Workers intern list types keyed by their member ids. Node.js prints +// the same. + +class List { + readonly members: readonly number[]; + readonly label: string; + constructor(members: readonly number[], label: string) { + this.members = members; + this.label = label; + } +} + +const lists = new InternTable("arrays.lists"); +const paths = new InternTable("arrays.paths"); + +function listOf(members: readonly number[]): List { + return lists.getOrInsertComputed(members, (key) => new List([...key], key.join("|"))); +} + +if (isMainThread) { + const a = listOf([1, 2, 3]); + const b = listOf([1, 2, 3]); + const c = listOf([1, 2]); + const d = listOf([3, 2, 1]); + console.log(a === b, a === c, a === d, a.label, c.label, d.label, lists.size); + console.log(lists.get([1, 2, 3]) === a, lists.get([1, 2, 3, 4]), lists.has([1, 2]), lists.has([2, 1])); + // SameValueZero elements. + const z = listOf([-0, NaN]); + console.log(listOf([0, 0 / 0]) === z, lists.has([0, NaN]), lists.has([NaN, 0]), lists.size); + // Empty and long keys. + const empty = listOf([]); + const long: number[] = []; + for (let i = 0; i < 500; i++) long.push(i * 1.5); + const big = listOf(long); + console.log(listOf([]) === empty, empty.label === "", listOf([...long]) === big, big.members.length); + long.push(1); + console.log(lists.has(long), lists.size); + // Holes differ from numbers. + const holed = new Array(3); + holed[0] = 1; + const again = new Array(3); + again[0] = 1; + console.log(listOf(holed).members.length, listOf(again) === listOf(holed), lists.has([1, 0, 0]), lists.has([1])); + + // String arrays compare by content, element by element. + console.log(paths.getOrInsert(["ab", "c"], 1), paths.getOrInsert(["a", "bc"], 2), paths.getOrInsert(["ab", "c"], 3)); + console.log(paths.get(["a" + "b", "c"]), paths.get(["abc"]), paths.get([]), paths.getOrInsert([], 4), paths.get([""])); + console.log(paths.getOrInsert([""], 5), paths.getOrInsert(["", ""], 6), paths.get([""]), paths.size); + + const results: string[] = []; + let pending = 3; + for (let w = 0; w < 3; w++) { + const worker = new Worker(new URL(import.meta.url), { workerData: { seed: w } }); + worker.on("message", (message: unknown) => { + results[w] = message as string; + }); + worker.on("exit", () => { + if (--pending === 0) for (const line of results) console.log(line); + }); + } +} else { + const { seed } = workerData as { seed: number }; + let total = 0; + let bad = 0; + for (let n = 0; n < 5000; n++) { + const k = (n * 13 + seed * 1667) % 5000; + const members = [k % 7, k % 11, k]; + const list = listOf(members); + if (list.label !== members.join("|")) bad++; + total += list.members[2]!; + } + parentPort!.postMessage(`worker ${seed}: total=${total} bad=${bad}`); +} diff --git a/tests/corpus/5725-intern-table-callbacks.ts b/tests/corpus/5725-intern-table-callbacks.ts new file mode 100644 index 000000000..ec3fb57f0 --- /dev/null +++ b/tests/corpus/5725-intern-table-callbacks.ts @@ -0,0 +1,73 @@ +import { InternTable } from "@scriptc/experimental"; + +// InternTable.getOrInsertComputed callbacks (@scriptc/experimental): the +// callback runs only when the key has no value, sees the key and the +// enclosing scope, and its side effects happen in the order Node.js runs +// them. Node.js prints the same. + +class Item { + readonly label: string; + readonly n: number; + constructor(label: string, n: number) { + this.label = label; + this.n = n; + } +} + +const items = new InternTable("callbacks.items"); +const log: string[] = []; +let made = 0; +function make(label: string): Item { + log.push(`make ${label}`); + return new Item(label, ++made); +} + +// Expression arrows, with and without the key parameter. +console.log(items.getOrInsertComputed("a", (k) => make(k)).n, items.getOrInsertComputed("a", (k) => make(k)).n); +console.log(items.getOrInsertComputed("b", () => make("bee")).label, made); +// A parameter that shadows an outer binding, and a wider parameter type. +const k = "outer"; +console.log(items.getOrInsertComputed("c", (k) => new Item(k + "!", 0)).label, k); +console.log(items.getOrInsertComputed("d", (key: string | number) => new Item(`${typeof key}:${key}`, 1)).label); +// The enclosing scope: reads and writes of outer variables. +let counter = 10; +const e = items.getOrInsertComputed("e", () => new Item("e", counter++)); +console.log(e.n, counter, items.getOrInsertComputed("e", () => new Item("e", counter++)).n, counter); +// Nested interning inside a callback. +function chain(depth: number): Item { + return items.getOrInsertComputed(`chain${depth}`, (key) => + depth === 0 ? new Item(key, 0) : new Item(`${key}>${chain(depth - 1).label}`, depth), + ); +} +console.log(chain(3).label, chain(3) === chain(3), items.has("chain0")); +// A callback that stores its own key, and one that throws. +const nested = items.getOrInsertComputed("self", (key) => (items.getOrInsert(key, new Item("inner", 1)), new Item("outer", 2))); +console.log(nested.label, items.get("self")?.label); +try { + items.getOrInsertComputed("boom", (key) => (key.length > 0 ? (() => { throw new Error(`no ${key}`); })() : new Item(key, 0))); +} catch (error) { + console.log((error as Error).message, items.has("boom")); +} +// Block bodies and function expressions. +console.log( + items.getOrInsertComputed("f", (key) => { + log.push(`block ${key}`); + return new Item(key, 3); + }).n, + items.getOrInsertComputed("g", function (key: string) { + return new Item(key.toUpperCase(), 4); + }).label, +); +// Methods that intern with `this` in the callback. +class Factory { + readonly prefix: string; + constructor(prefix: string) { + this.prefix = prefix; + } + item(name: string): Item { + return items.getOrInsertComputed(`${this.prefix}${name}`, (key) => new Item(`${key}/${this.prefix}`, 5)); + } +} +const factory = new Factory("p-"); +console.log(factory.item("x").label, factory.item("x") === factory.item("x")); +console.log(log.join("; "), items.size); diff --git a/tests/harness/__snapshots__/intern-table-fences.ts.txt b/tests/harness/__snapshots__/intern-table-fences.ts.txt index 51186d9d2..e06c04c9b 100644 --- a/tests/harness/__snapshots__/intern-table-fences.ts.txt +++ b/tests/harness/__snapshots__/intern-table-fences.ts.txt @@ -23,7 +23,7 @@ intern-table-fences.ts:11:15 - error SC1090: 'string | number' as an InternTable | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 12 | const callbacks = new InternTable number>("fences.callbacks"); - hint: use string or number keys + hint: use string or number keys, or arrays of numbers or of strings intern-table-fences.ts:12:19 - error SC1090: '() => number' as an InternTable value type is not supported yet From 8dc5dc124900a33bd5fc0254d252e77c6c942041 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 21:44:38 -0500 Subject: [PATCH 2/2] Refuse InternTable array keys that are too long to encode Encoding an array key added each element's size to a running length with unchecked arithmetic, and wrote into a fixed stack buffer whenever the sum fit. A sparse array with a large length (2^29 elements or more) wrapped the sum on 32-bit targets, so the write landed before the buffer; on every target the encoder also visited every index of such an array. Array keys now have at most 65536 elements, and their encoding at most 2^31 - 1 bytes. The size is computed first with sums that cannot wrap, the buffer is sized to it, and keys over either limit make the lookup or insert throw a TypeError before anything is read or written. The Node.js implementation throws the same TypeError for keys of more than 65536 elements. Sparse elements are walked in one pass in index order, and the index growth computation checks its size arithmetic too. --- docs/content/docs/experimental.mdx | 2 +- packages/compiler/src/backend/llvm/emitter.ts | 6 +- packages/compiler/src/backend/may-throw.ts | 6 + packages/compiler/src/ir/ir.ts | 8 +- .../5726-intern-table-key-limits.ts.json | 6 + packages/experimental/README.md | 2 +- packages/experimental/index.d.ts | 3 +- packages/experimental/index.js | 5 +- packages/runtime/src/scr_itable.c | 111 +++++++++++++----- packages/runtime/src/scr_itable.h | 4 +- packages/runtime/src/scr_itable.test.c | 35 +++++- tests/corpus/5726-intern-table-key-limits.ts | 39 ++++++ tests/harness/wasi-differential.test.ts | 2 + 13 files changed, 191 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json create mode 100644 tests/corpus/5726-intern-table-key-limits.ts diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index 68026072b..64481634f 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -159,7 +159,7 @@ worker: square: 4 lines, 3 shapes
new InternTable<K, V>(name)
-
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like Map keys; arrays compare by their elements in the same way.
+
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like Map keys; arrays compare by their elements in the same way and can have at most 65536 elements.
table.get(key), table.has(key), table.size
Read the table.
table.getOrInsert(key, value)
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index c1c9d1591..1b7fd93df 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -8650,7 +8650,8 @@ export class LlEmitter { /** InternTable's runtime calls (scr_itable.c). Table and entry handles * are f64 values carrying pointer bits; entry values are 64-bit slots * (double bits, a bool, or a pointer to an immortal value) that the - * table neither retains nor releases. */ + * table neither retains nor releases. Lookups and inserts of array keys + * throw a TypeError for keys longer than a table accepts. */ private emitInternTableIntrinsic(e: ExprOf<"intrinsic">): LlValue { const B = this.B; // The runtime copies keys and stores published values without counts, @@ -8692,6 +8693,8 @@ export class LlEmitter { const call = keyCall("find"); const entry = B.tmp(); B.line(`${entry} = call ptr ${call})`); + // Array keys longer than a table accepts throw a TypeError. + if (e.args[1]!.type.kind === "array") this.emitPendingCheck(); return toHandle(entry); } case "itable.insert": { @@ -8711,6 +8714,7 @@ export class LlEmitter { const call = keyCall("insert"); const entry = B.tmp(); B.line(`${entry} = call ptr ${call}, i64 ${slot})`); + if (e.args[1]!.type.kind === "array") this.emitPendingCheck(); return toHandle(entry); } case "itable.value": { diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 9d692a15d..1c65da36f 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -196,6 +196,12 @@ export function computeMayThrow( if (rec.name === "module.tdzCheck") f.throws = true; // publish() throws on an unpublishable dyn value. if (rec.name === "threads.publish") f.throws = true; + // An InternTable array key longer than a table accepts. + if ( + (rec.name === "itable.find" || rec.name === "itable.insert") && + rec.args[1]?.type.kind === "array" + ) + f.throws = true; break; case "yieldExpr": // A consumer .throw() surfaces at the yield (and .return()'s diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b45a36ed7..19b83560e 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3482,9 +3482,11 @@ export type IrExpr = * key's entry, storing the already published value when the key has * none; itable.value (f64 entry) → V, the entry's immortal value; * itable.size (f64 table) → f64. K is string, f64, or an array of - * f64 or of strings (compared by elements). Handles carry - * raw pointer bits and only reach toBool and these intrinsics. - * None of them throws. */ + * f64 or of strings (compared by elements); find and insert throw + * a TypeError for array keys of more than 65536 elements or whose + * encoding exceeds 2^31 - 1 bytes; nothing else throws. Handles + * carry raw pointer bits and only reach toBool and these + * intrinsics. */ | "itable.open" | "itable.find" | "itable.insert" diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json new file mode 100644 index 000000000..5db8a9473 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5726-intern-table-key-limits.ts" + ], + "diags": [] +} diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 5dc9775df..413deeff2 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -55,7 +55,7 @@ function unionOf(members: readonly Type[]): Type { ``` - `new InternTable(name)` opens the table called `name`. Within one thread, every construction with the same name refers to the same table. In scriptc builds the table is shared by every thread of the process; under Node.js each thread has its own. -- Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like `Map` keys: strings by content, numbers by SameValueZero. Arrays compare by their elements in the same way, so a freshly built `[a.id, b.id]` finds the value stored under an equal array; holes are equal to each other and to no number or string. +- Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like `Map` keys: strings by content, numbers by SameValueZero. Arrays compare by their elements in the same way, so a freshly built `[a.id, b.id]` finds the value stored under an equal array; holes are equal to each other and to no number or string. An array key can have at most 65536 elements; longer ones make every method throw a `TypeError`. - `get(key)`, `has(key)` and `size` read the table. - `getOrInsert(key, value)` returns the value stored under `key`. When there is none, it publishes `value`, stores it and returns it. - `getOrInsertComputed(key, callback)` returns the value stored under `key`. When there is none, it calls `callback(key)`, publishes the result and stores it, unless a value was stored under `key` meanwhile (by the callback, or in scriptc builds by another thread), and returns the stored value. diff --git a/packages/experimental/index.d.ts b/packages/experimental/index.d.ts index e5079112a..d831cbff9 100644 --- a/packages/experimental/index.d.ts +++ b/packages/experimental/index.d.ts @@ -35,7 +35,8 @@ export declare class InternTable< /** The value stored under `key`, or undefined. String and number keys * compare like Map keys: strings by content, numbers by SameValueZero. * Array keys compare by their elements in the same way; a hole and an - * undefined element are equal. */ + * undefined element are equal. Every method throws a TypeError for an + * array key of more than 65536 elements. */ get(key: K): V | undefined; /** Whether `key` has a value. */ has(key: K): boolean; diff --git a/packages/experimental/index.js b/packages/experimental/index.js index 6c4af68ba..8a1b28c26 100644 --- a/packages/experimental/index.js +++ b/packages/experimental/index.js @@ -24,9 +24,12 @@ const tables = new Map(); /** The Map key of a table key: strings and numbers as they are, arrays as a * string that encodes each element (numbers by SameValueZero, strings by - * content, holes and undefined elements alike). */ + * content, holes and undefined elements alike). Arrays of more than 65536 + * elements are refused, as in scriptc builds. */ function keyOf(key) { if (!Array.isArray(key)) return key; + if (key.length > 65536) + throw new TypeError("InternTable array keys can have at most 65536 elements"); let text = ""; for (let i = 0; i < key.length; i++) { const element = key[i]; diff --git a/packages/runtime/src/scr_itable.c b/packages/runtime/src/scr_itable.c index dcdcf481c..08db948b5 100644 --- a/packages/runtime/src/scr_itable.c +++ b/packages/runtime/src/scr_itable.c @@ -10,7 +10,8 @@ * tag of hash bits 50..57 in bits 48..55, so a probe reads an entry only * when the tags match. An array key is stored as one byte string that * encodes its elements (scr_itable_encode), so array keys compare by - * their elements. + * their elements; keys longer or larger than a table accepts are refused + * with a TypeError before anything is encoded. * * Readers load the segment's index pointer, then slots with acquire order, * and never write. Writers hold the segment's lock: they probe again, grow @@ -44,6 +45,12 @@ #define SCR_ITABLE_FIRST_SLOTS 8u #define SCR_ITABLE_FIRST_CHUNK ((size_t)512) #define SCR_ITABLE_MAX_CHUNK ((size_t)64 << 10) +/* Keys whose bytes (a string's, or an array key's encoding) exceed this + * are refused, which keeps every entry size representable on 32-bit + * targets. Array keys of more than SCR_ITABLE_MAX_KEY_ELEMENTS elements are + * refused too; the Node.js implementation applies the same limit. */ +#define SCR_ITABLE_MAX_KEY_BYTES ((size_t)SCR_STR_MAX_CAP) +#define SCR_ITABLE_MAX_KEY_ELEMENTS 65536u /* String slots: 0 is empty; otherwise the entry pointer with the tag in * bits 48..55, which no supported target uses for user-space addresses @@ -314,7 +321,10 @@ const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key) { /* ── inserts (the segment lock held) ─────────────────────────────────── */ static ScrITableIndex *scr_itable_index_new(uint64_t slots, bool numbers) { - size_t words = slots * (numbers ? 2 : 1) * sizeof(uint64_t); + /* Bytes per slot: the words and, for numbers, the tag byte. */ + size_t per = (numbers ? 2 : 1) * sizeof(uint64_t) + (numbers ? 1 : 0); + if (slots > (SIZE_MAX - sizeof(ScrITableIndex)) / per) scr_itable_oom(); + size_t words = (size_t)slots * (numbers ? 2 : 1) * sizeof(uint64_t); ScrITableIndex *index = calloc(1, sizeof(ScrITableIndex) + words + (numbers ? slots : 0)); if (!index) scr_itable_oom(); index->mask = slots - 1; @@ -394,7 +404,7 @@ static void *scr_itable_alloc(ScrITableSegment *seg, size_t size) { * unless the key has an entry. */ static const ScrITableEntry *scr_itable_insert_bytes(ScrITable *t, uint64_t hash, const char *bytes, size_t len, uint64_t value) { - if (len > UINT32_MAX) scr_trap("scriptc: InternTable key is too long\n"); + if (len > SCR_ITABLE_MAX_KEY_BYTES) scr_trap("scriptc: InternTable key is too long\n"); ScrITableSegment *seg = scr_itable_segment(t, hash); scr_itable_lock(&seg->lock); const ScrITableStrEntry *found = @@ -444,8 +454,16 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v /* ── array keys ──────────────────────────────────────────────────────── */ -/* The state and slot of element `i` of `a` (i < a->len). */ -static ScrArrState scr_itable_element(const ScrArr *a, size_t i, uint64_t *slot) { +/* A walk over the elements of an array in index order. Sparse entries + * are sorted by index (scr_array.h), so one pass visits each once. */ +typedef struct { + const ScrArr *a; + size_t sparse; +} ScrITableElements; + +/* The state and slot of element `i` (i < len, increasing across calls). */ +static ScrArrState scr_itable_element(ScrITableElements *w, size_t i, uint64_t *slot) { + const ScrArr *a = w->a; if (i < a->filled) { *slot = a->data[i]; return SCR_ARR_VALUE; @@ -454,28 +472,65 @@ static ScrArrState scr_itable_element(const ScrArr *a, size_t i, uint64_t *slot) *slot = a->data[i]; return (ScrArrState)a->present[i]; } - if (a->ext) - for (size_t j = 0; j < a->ext->sparse_len; j++) - if (a->ext->sparse[j].index == i) { - *slot = a->ext->sparse[j].slot; - return (ScrArrState)a->ext->sparse[j].state; - } + if (a->ext) { + while (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index < i) w->sparse++; + if (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index == i) { + *slot = a->ext->sparse[w->sparse].slot; + return (ScrArrState)a->ext->sparse[w->sparse].state; + } + } return SCR_ARR_HOLE; } /* An array key as bytes: per number element its normalized bits, per * string element a 32-bit length and the bytes; a hole or undefined * element is the bits of a NaN that normalization never produces, or the - * length 0xffffffff. Answers the encoded length; writes at most `cap` - * bytes into `out`. */ + * length 0xffffffff. */ #define SCR_ITABLE_ABSENT_F64 UINT64_C(0x7ff4000000000002) #define SCR_ITABLE_ABSENT_STR UINT32_C(0xffffffff) -static size_t scr_itable_encode(const ScrArr *key, int kind, char *out, size_t cap) { +static void scr_itable_refuse(const char *message) { + scr_throw_error_msg(SCR_ERR_TYPE, message, strlen(message)); +} + +/* The encoded size of `key`, or false (with a pending TypeError) when the + * key is longer or larger than a table accepts. Every sum stays at most + * SCR_ITABLE_MAX_KEY_BYTES, so none can wrap. */ +static bool scr_itable_encoded_size(const ScrArr *key, int kind, size_t *out) { + if (key->len > SCR_ITABLE_MAX_KEY_ELEMENTS) { + scr_itable_refuse("InternTable array keys can have at most 65536 elements"); + return false; + } size_t n = 0; + ScrITableElements walk = {key, 0}; + for (size_t i = 0; i < key->len; i++) { + size_t add = kind == SCR_ITABLE_KEY_F64S ? 8 : 4; + if (kind != SCR_ITABLE_KEY_F64S) { + uint64_t slot = 0; + if (scr_itable_element(&walk, i, &slot) == SCR_ARR_VALUE) { + const ScrStr *s = (const ScrStr *)(uintptr_t)slot; + if (s->len > SCR_ITABLE_MAX_KEY_BYTES - add) add = SCR_ITABLE_MAX_KEY_BYTES + 1; + else add += s->len; + } + } + if (add > SCR_ITABLE_MAX_KEY_BYTES - n) { + scr_itable_refuse("InternTable array keys can encode at most 2147483647 bytes"); + return false; + } + n += add; + } + *out = n; + return true; +} + +/* Writes the encoding of `key` into `out`, which holds exactly the size + * scr_itable_encoded_size answered. */ +static void scr_itable_encode(const ScrArr *key, int kind, char *out) { + size_t n = 0; + ScrITableElements walk = {key, 0}; for (size_t i = 0; i < key->len; i++) { uint64_t slot = 0; - bool present = scr_itable_element(key, i, &slot) == SCR_ARR_VALUE; + bool present = scr_itable_element(&walk, i, &slot) == SCR_ARR_VALUE; if (kind == SCR_ITABLE_KEY_F64S) { uint64_t bits = SCR_ITABLE_ABSENT_F64; if (present) { @@ -483,21 +538,19 @@ static size_t scr_itable_encode(const ScrArr *key, int kind, char *out, size_t c memcpy(&d, &slot, sizeof d); bits = scr_itable_f64_bits(d); } - if (n + 8 <= cap) memcpy(out + n, &bits, 8); + memcpy(out + n, &bits, 8); n += 8; } else { const ScrStr *s = present ? (const ScrStr *)(uintptr_t)slot : NULL; - if (s && s->len >= SCR_ITABLE_ABSENT_STR) scr_trap("scriptc: InternTable key is too long\n"); uint32_t len = s ? (uint32_t)s->len : SCR_ITABLE_ABSENT_STR; - if (n + 4 <= cap) memcpy(out + n, &len, 4); + memcpy(out + n, &len, 4); n += 4; if (s) { - if (n + s->len <= cap) memcpy(out + n, s->data, s->len); + memcpy(out + n, s->data, s->len); n += s->len; } } } - return n; } static inline uint64_t scr_itable_bytes_hash(const char *bytes, size_t len) { @@ -505,14 +558,16 @@ static inline uint64_t scr_itable_bytes_hash(const char *bytes, size_t len) { } /* Encodes `key` into `stack` when it fits, else into a heap buffer the - * caller frees. */ + * caller frees; NULL (with a pending TypeError) for a refused key. */ static char *scr_itable_encoded(const ScrITable *t, const ScrArr *key, char *stack, size_t cap, size_t *len) { - *len = scr_itable_encode(key, t->key_kind, stack, cap); - if (*len <= cap) return stack; - char *heap = malloc(*len); - if (!heap) scr_itable_oom(); - scr_itable_encode(key, t->key_kind, heap, *len); - return heap; + if (!scr_itable_encoded_size(key, t->key_kind, len)) return NULL; + char *bytes = stack; + if (*len > cap) { + bytes = malloc(*len); + if (!bytes) scr_itable_oom(); + } + scr_itable_encode(key, t->key_kind, bytes); + return bytes; } #define SCR_ITABLE_STACK_KEY 256 @@ -522,6 +577,7 @@ const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key) { char stack[SCR_ITABLE_STACK_KEY]; size_t len; char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + if (!bytes) return NULL; uint64_t hash = scr_itable_bytes_hash(bytes, len); ScrITableSegment *seg = scr_itable_segment(t, hash); const ScrITableEntry *found = (const ScrITableEntry *)scr_itable_probe_bytes( @@ -535,6 +591,7 @@ const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uin char stack[SCR_ITABLE_STACK_KEY]; size_t len; char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + if (!bytes) return NULL; const ScrITableEntry *entry = scr_itable_insert_bytes(t, scr_itable_bytes_hash(bytes, len), bytes, len, value); if (bytes != stack) free(bytes); return entry; diff --git a/packages/runtime/src/scr_itable.h b/packages/runtime/src/scr_itable.h index 9ea3258dd..1c198182f 100644 --- a/packages/runtime/src/scr_itable.h +++ b/packages/runtime/src/scr_itable.h @@ -38,7 +38,9 @@ typedef struct ScrITableEntry { /* The table called `name`, created on first use. Traps when the name is * already open with another key kind. Never returns NULL. */ ScrITable *scr_itable_open(const ScrStr *name, int key_kind); -/* The entry of `key`, or NULL. */ +/* The entry of `key`, or NULL. The array variants refuse keys of more + * than 65536 elements, or whose encoding exceeds 2^31 - 1 bytes, with a + * pending TypeError (and answer NULL). */ const ScrITableEntry *scr_itable_find_str(ScrITable *t, const ScrStr *key); const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key); const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key); diff --git a/packages/runtime/src/scr_itable.test.c b/packages/runtime/src/scr_itable.test.c index 7781d4d01..0f218806b 100644 --- a/packages/runtime/src/scr_itable.test.c +++ b/packages/runtime/src/scr_itable.test.c @@ -8,12 +8,20 @@ #include #include -/* The table unit needs only scr_trap from the rest of the runtime. */ +/* The table unit needs only scr_trap and scr_throw_error_msg from the + * rest of the runtime; refusals are recorded here. */ _Noreturn void scr_trap(const char *msg) { fputs(msg, stderr); abort(); } +static char refused[128]; +void scr_throw_error_msg(int kind, const char *msg, size_t len) { + assert(kind == SCR_ERR_TYPE && len < sizeof refused); + memcpy(refused, msg, len); + refused[len] = 0; +} + enum { THREADS = 8, KEYS = 20000, ROUNDS = 3 }; /* A stand-in for a published value: written by its creator before the @@ -161,6 +169,19 @@ int main(void) { big->data = calloc(100, sizeof(uint64_t)); for (unsigned i = 0; i < 100; i++) memcpy(&big->data[i], &(double){i}, sizeof(double)); assert(scr_itable_insert_arr(lists, big, 6)->value == 6 && scr_itable_find_arr(lists, big)->value == 6); + /* Keys of more than 65536 elements are refused before anything is read, + * however long their sparse length. */ + ScrArr *longest = make_f64s(NULL, 0); + longest->len = 65536; + assert(scr_itable_insert_arr(lists, longest, 7)->value == 7 && !refused[0]); + ScrArr *huge = make_f64s(NULL, 0); + huge->len = (size_t)1 << 29; + assert(!scr_itable_find_arr(lists, huge) && !strcmp(refused, "InternTable array keys can have at most 65536 elements")); + refused[0] = 0; + longest->len = 65537; + assert(!scr_itable_insert_arr(lists, longest, 8) && refused[0]); + refused[0] = 0; + assert(scr_itable_size(lists) == 6); ScrITable *paths = scr_itable_open(paths_name, SCR_ITABLE_KEY_STRS); ScrStr *ab = make_str("ab"), *c = make_str("c"), *a = make_str("a"), *bc = make_str("bc"); ScrArr *p1 = make_strs((ScrStr *[]){ab, c}, 2), *p2 = make_strs((ScrStr *[]){a, bc}, 2); @@ -168,7 +189,17 @@ int main(void) { const ScrITableEntry *e1 = scr_itable_insert_arr(paths, p1, 7); assert(scr_itable_insert_arr(paths, p2, 8) != e1 && scr_itable_find_arr(paths, p3) == e1); assert(scr_itable_size(paths) == 2); - ScrArr *arrays[] = {one, same, shorter, holed, empty_list, big, p1, p2, p3}; + /* String keys whose encoding exceeds 2^31 - 1 bytes are refused. */ + ScrStr *wide = calloc(1, sizeof(ScrStr) + 1); + assert(wide); + wide->rc = 1; + wide->len = (size_t)0x7fffffff - 2; + ScrArr *heavy = make_strs((ScrStr *[]){wide, wide}, 2); + assert(!scr_itable_find_arr(paths, heavy) && + !strcmp(refused, "InternTable array keys can encode at most 2147483647 bytes")); + refused[0] = 0; + free(wide); + ScrArr *arrays[] = {one, same, shorter, holed, empty_list, big, longest, huge, p1, p2, p3, heavy}; for (unsigned i = 0; i < sizeof arrays / sizeof *arrays; i++) { free(arrays[i]->data); free(arrays[i]->present); diff --git a/tests/corpus/5726-intern-table-key-limits.ts b/tests/corpus/5726-intern-table-key-limits.ts new file mode 100644 index 000000000..cd87f1b3a --- /dev/null +++ b/tests/corpus/5726-intern-table-key-limits.ts @@ -0,0 +1,39 @@ +import { InternTable } from "@scriptc/experimental"; + +// InternTable (@scriptc/experimental) array keys can have at most 65536 +// elements: longer keys, including sparse arrays with a large length, make +// every method throw a TypeError and store nothing. Node.js prints the same. + +const lists = new InternTable("limits.lists"); +const names = new InternTable("limits.names"); + +function attempt(what: string, run: () => unknown): void { + try { + console.log(what, run()); + } catch (error) { + console.log(what, (error as Error).name, (error as Error).message); + } +} + +const longest = new Array(65536); +longest[0] = 1; +attempt("longest", () => lists.getOrInsert(longest, 1)); +attempt("longest again", () => lists.get(longest)); + +const longer = new Array(65537); +attempt("longer get", () => lists.get(longer)); +attempt("longer has", () => lists.has(longer)); +attempt("longer insert", () => lists.getOrInsert(longer, 2)); +attempt("longer computed", () => lists.getOrInsertComputed(longer, () => 3)); + +const sparse: number[] = [1, 2]; +sparse.length = 2 ** 29; +attempt("sparse get", () => lists.get(sparse)); +attempt("sparse insert", () => lists.getOrInsert(sparse, 4)); +attempt("sparse computed", () => lists.getOrInsertComputed(sparse, () => 5)); + +const words: string[] = ["a"]; +words.length = 2 ** 30; +attempt("words get", () => names.get(words)); +attempt("words insert", () => names.getOrInsert(words, 6)); +console.log(lists.size, names.size, lists.get([1]), names.get(["a"])); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 334124b4e..d2cd28f2f 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -289,6 +289,8 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { // InternTable handles carry 32-bit pointers in f64 slots. "5720-intern-table-basics.ts", "5721-intern-table-values.ts", + // Array key sizes are checked in 32-bit size_t arithmetic. + "5726-intern-table-key-limits.ts", ])("the LLVM target runs %s byte-identically to Node", async (fixture) => { const entry = join(repoRoot, "tests/corpus", fixture); const outDir = await mkdtemp("/tmp/scriptc-wasi-");