diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx
index 4ee174fc6..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 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 and can have at most 65536 elements.
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 77db5437f..b8f25d477 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -8833,7 +8833,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,
@@ -8855,8 +8856,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}`;
@@ -8874,6 +8876,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": {
@@ -8893,6 +8897,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/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/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/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 e4002b136..a4dce311e 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -3481,9 +3481,12 @@ 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
- * raw pointer bits and only reach toBool and these intrinsics.
- * None of them throws. */
+ * itable.size (f64 table) → f64. K is string, f64, or an array of
+ * 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/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index 5e75a2ace..3fe0f6e07 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/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 3b22148b8..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 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. 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.
@@ -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..d831cbff9 100644
--- a/packages/experimental/index.d.ts
+++ b/packages/experimental/index.d.ts
@@ -24,13 +24,19 @@ 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. 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 5f1fe64fb..8a1b28c26 100644
--- a/packages/experimental/index.js
+++ b/packages/experimental/index.js
@@ -22,6 +22,24 @@ 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). 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];
+ 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 +58,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 +82,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..08db948b5 100644
--- a/packages/runtime/src/scr_itable.c
+++ b/packages/runtime/src/scr_itable.c
@@ -5,9 +5,13 @@
* 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; 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
@@ -15,13 +19,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"
@@ -42,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
@@ -50,17 +59,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 +139,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 +261,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 +276,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 +285,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 +320,58 @@ 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) {
+ /* 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;
+ 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 +400,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 > 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 =
- 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 +426,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 +441,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 +452,151 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v
return (const ScrITableEntry *)found;
}
+/* ── array keys ──────────────────────────────────────────────────────── */
+
+/* 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;
+ }
+ if (i < a->cap) {
+ *slot = a->data[i];
+ return (ScrArrState)a->present[i];
+ }
+ 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. */
+#define SCR_ITABLE_ABSENT_F64 UINT64_C(0x7ff4000000000002)
+#define SCR_ITABLE_ABSENT_STR UINT32_C(0xffffffff)
+
+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(&walk, 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);
+ }
+ memcpy(out + n, &bits, 8);
+ n += 8;
+ } else {
+ const ScrStr *s = present ? (const ScrStr *)(uintptr_t)slot : NULL;
+ uint32_t len = s ? (uint32_t)s->len : SCR_ITABLE_ABSENT_STR;
+ memcpy(out + n, &len, 4);
+ n += 4;
+ if (s) {
+ memcpy(out + n, s->data, s->len);
+ n += s->len;
+ }
+ }
+ }
+}
+
+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; 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) {
+ 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
+
+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);
+ 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(
+ 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);
+ 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;
+}
+
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..1c198182f 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;
@@ -35,16 +38,22 @@ 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);
/* 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..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
@@ -34,6 +42,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 +112,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 +143,75 @@ 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);
+ /* 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);
+ 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);
+ /* 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);
+ 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/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/__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
diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts
index 0df85a68d..178ed2191 100644
--- a/tests/harness/wasi-differential.test.ts
+++ b/tests/harness/wasi-differential.test.ts
@@ -305,6 +305,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-");