diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 57d1eaf1a..c69ec43d9 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -10,9 +10,11 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs | | `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any | | `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` | +| `date-math` | cpu | schedules built with Date setters (local and UTC), weekday and month-end rules through the getters, Dates sorted by time value | | `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner | | `dictionary-walks` | cpu | `Record` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted | | `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods | +| `dynamic-properties` | cpu | untyped JavaScript reading and writing properties of values it does not type: records built by object literals in two key orders, nested records, and instances of a class and its subclass | | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | @@ -29,6 +31,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` | | `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds | | `template-render` | cpu | template literals, escaping, `map`/`join` | +| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative | | `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread | | `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks | | `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage | diff --git a/benchmarks/runtime/workloads/date-math.json b/benchmarks/runtime/workloads/date-math.json new file mode 100644 index 000000000..b43078582 --- /dev/null +++ b/benchmarks/runtime/workloads/date-math.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/date-math.ts", + "args": [ + "40" + ] +} diff --git a/benchmarks/runtime/workloads/date-math.ts b/benchmarks/runtime/workloads/date-math.ts new file mode 100644 index 000000000..56ec1066e --- /dev/null +++ b/benchmarks/runtime/workloads/date-math.ts @@ -0,0 +1,52 @@ +// Calendar arithmetic on Dates: schedules built by adjusting Dates with the +// UTC and local setters, weekday and month-end rules read back through the +// getters, Dates compared and sorted by time value, and ISO text for a +// sample. Argument: the number of rounds. +process.env.TZ = "America/New_York"; + +const rounds = Number(process.argv[2] ?? "40"); +let seed = 2026; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +/** The next business day at 09:00 local time on or after `from`. */ +function nextBusinessDay(from: Date): Date { + const day = new Date(from.getTime()); + day.setHours(9, 0, 0, 0); + if (day < from) day.setDate(day.getDate() + 1); + while (day.getDay() === 0 || day.getDay() === 6) day.setDate(day.getDate() + 1); + return day; +} + +/** The last day of `date`'s month, in UTC. */ +function monthEnd(date: Date): number { + const end = new Date(date.getTime()); + end.setUTCDate(1); + end.setUTCMonth(end.getUTCMonth() + 1); + end.setUTCDate(0); + return end.getUTCDate(); +} + +let checksum = 0; +let sample = ""; +for (let round = 0; round < rounds; round++) { + const due: Date[] = []; + const cursor = new Date(Date.UTC(2020 + (round % 7), 0, 1, 12)); + for (let i = 0; i < 4000; i++) { + cursor.setUTCMinutes(cursor.getUTCMinutes() + Math.floor(random() * 4000)); + const next = nextBusinessDay(cursor); + checksum += next.getDate() + monthEnd(next) + next.getHours(); + if (i % 3 === 0) next.setMonth(next.getMonth() + 1, 15); + due.push(next); + } + due.sort((a, b) => +a - +b); + let previous = due[0]!; + for (const date of due) { + checksum += (+date - +previous) / 3600000; + previous = date; + } + sample = due[due.length >> 1]!.toISOString(); +} +console.log(Math.round(checksum), sample); diff --git a/benchmarks/runtime/workloads/dynamic-properties.json b/benchmarks/runtime/workloads/dynamic-properties.json new file mode 100644 index 000000000..398981ef3 --- /dev/null +++ b/benchmarks/runtime/workloads/dynamic-properties.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/dynamic-properties.mjs", + "args": [ + "1000000" + ] +} diff --git a/benchmarks/runtime/workloads/dynamic-properties.mjs b/benchmarks/runtime/workloads/dynamic-properties.mjs new file mode 100644 index 000000000..d0ee3802c --- /dev/null +++ b/benchmarks/runtime/workloads/dynamic-properties.mjs @@ -0,0 +1,56 @@ +// Untyped JavaScript reading and writing properties of values it never +// declares types for: records built by object literals in two key orders, +// nested records, property updates, and instances of a class and its +// subclass read and written the same way. Argument: the number of +// iterations. +const rounds = Number(process.argv[2] ?? "1000000"); +class Cursor { + constructor(x, y) { + this.x = x; + this.y = y; + } + step(dx, dy) { + this.x += dx; + this.y += dy; + return this; + } +} +class Marker extends Cursor { + constructor(x, y, label) { + super(x, y); + this.label = label; + } +} +function makeItem(i) { + if (i % 3 === 0) + return { id: i, price: i % 17, qty: i % 5, meta: { tag: i % 7, weight: i % 11 } }; + return { id: i, qty: i % 5, price: i % 13, meta: { weight: i % 9, tag: i % 5 } }; +} +function cost(item) { + return item.price * item.qty + item.meta.weight; +} +function restock(item, round) { + item.qty = (item.qty + round) % 7; + item.meta.tag = (item.meta.tag + 1) % 7; +} +function distance(a, b) { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} +const items = []; +for (let i = 0; i < 1024; i++) items.push(makeItem(i)); +const cursors = []; +for (let i = 0; i < 64; i++) + cursors.push(i % 2 === 0 ? new Cursor(i - 32, 7 - i) : new Marker(i - 32, 7 - i, "m" + i)); +const origin = { x: 0, y: 0 }; +let checksum = 0; +for (let r = 0; r < rounds; r++) { + const item = items[r & 1023]; + checksum += cost(item); + restock(item, r & 3); + checksum += item.meta.tag; + const cursor = cursors[r & 63]; + checksum += distance(cursor.step((r % 3) - 1, (r % 5) - 2), origin); + if (cursor.x > 100 || cursor.x < -100) cursor.x = 0; + if (cursor.y > 100 || cursor.y < -100) cursor.y = 0; +} +console.log(checksum, items[5].qty, items[6].meta.tag, cursors[3].x, cursors[3].y, cursors[3].label); diff --git a/benchmarks/runtime/workloads/throw-chains.json b/benchmarks/runtime/workloads/throw-chains.json new file mode 100644 index 000000000..79cf5ea87 --- /dev/null +++ b/benchmarks/runtime/workloads/throw-chains.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/throw-chains.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/throw-chains.ts b/benchmarks/runtime/workloads/throw-chains.ts new file mode 100644 index 000000000..c96816aed --- /dev/null +++ b/benchmarks/runtime/workloads/throw-chains.ts @@ -0,0 +1,196 @@ +// Exceptions that cross many frames: throws from helpers below a recursion, +// caught 2, 5 and 20 recursive calls up (direct calls, then calls through a +// callback), with each frame holding an array across its call, and a +// backtracking parser that uses exceptions for control flow. +class ParseError extends Error { + readonly at: number; + constructor(at: number, message: string) { + super(message); + this.at = at; + } +} + +/** A thrown value that is not an Error (no stack to capture). */ +class Stop { + readonly code: number; + constructor(code: number) { + this.code = code; + } +} + +const held: number[][] = []; +function fail(n: number, plain: boolean): number { + if (plain) throw new Stop(n); + throw new RangeError("chain " + n); +} +function check(n: number, plain: boolean): number { + return (n & 3) === 0 ? fail(n, plain) : n; +} +function validate(n: number, plain: boolean): number { + return check(n, plain) + (n & 1); +} +function bottom(n: number, plain: boolean): number { + const box = [n, -1]; + const r = validate(n, plain); + held[0] = box; + return r + box.length; +} +function walk(n: number, depth: number, plain: boolean): number { + const box = [n, depth]; + const r = depth === 0 ? bottom(n, plain) : walk(n, depth - 1, plain); + held[depth] = box; + return r + box.length; +} + +function chains(rounds: number): string { + const out: string[] = []; + for (const depth of [2, 5, 20]) + for (const plain of [false, true]) { + let caught = 0; + let sum = 0; + for (let i = 0; i < rounds; i++) { + try { + sum += walk(i, depth - 2, plain); + } catch (error) { + caught++; + sum += error instanceof Stop ? error.code & 7 : 1; + } + } + out.push(`depth ${depth} ${plain ? "object" : "error"}: caught ${caught} sum ${sum}`); + } + return out.join("\n"); +} + +// The same chains through a callback: each level visits the next with a +// closure, as tree visitors do. +function visit(n: number, depth: number, each: (n: number, depth: number) => number): number { + const box = [n, depth]; + const r = depth === 0 ? bottom(n, false) : each(n, depth - 1); + held[depth] = box; + return r + box.length; +} +function visits(rounds: number): string { + const out: string[] = []; + const each = (n: number, depth: number): number => visit(n, depth, each); + for (const depth of [2, 5, 20]) { + let caught = 0; + let sum = 0; + for (let i = 0; i < rounds; i++) { + try { + sum += visit(i, depth - 2, each); + } catch (error) { + caught++; + sum += error instanceof RangeError ? 1 : 0; + } + } + out.push(`visit depth ${depth}: caught ${caught} sum ${sum}`); + } + return out.join("\n"); +} + +// expr := term (("+" | "-") term)*; term := atom ("*" atom)*; +// atom := number | "(" expr ")" | name "(" expr ("," expr)* ")" | name. +// A call and a name start alike: the parser tries the call first and +// backtracks when it fails. +let text = ""; +let pos = 0; +function peek(): string { + return pos < text.length ? text[pos]! : ""; +} +function expect(c: string): void { + if (peek() !== c) throw new ParseError(pos, `expected ${c}`); + pos++; +} +function name(): string { + const start = pos; + while (pos < text.length && text.charCodeAt(pos) >= 97 && text.charCodeAt(pos) <= 122) pos++; + if (pos === start) throw new ParseError(pos, "expected a name"); + return text.slice(start, pos); +} +function call(): number { + const callee = name(); + expect("("); + let value = expr() + callee.length; + while (peek() === ",") { + pos++; + value += expr(); + } + expect(")"); + return value; +} +function atom(): number { + const c = peek(); + if (c >= "0" && c <= "9") { + let value = 0; + while (peek() >= "0" && peek() <= "9") value = value * 10 + (text.charCodeAt(pos++) - 48); + return value; + } + if (c === "(") { + pos++; + const value = expr(); + expect(")"); + return value; + } + const start = pos; + try { + return call(); + } catch (error) { + if (!(error instanceof ParseError)) throw error; + pos = start; + return name().length; + } +} +function term(): number { + let value = atom(); + while (peek() === "*") { + pos++; + value = (value * atom()) % 1000003; + } + return value; +} +function expr(): number { + let value = term(); + while (peek() === "+" || peek() === "-") { + const op = text[pos++]; + value = op === "+" ? value + term() : value - term(); + } + return value; +} + +let seed = 4242; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} +function source(depth: number): string { + const r = random(); + if (depth > 6 || r < 0.25) return String(Math.floor(random() * 100)); + if (r < 0.45) return "abcdefgh".slice(0, 1 + Math.floor(random() * 8)); + if (r < 0.6) return `(${source(depth + 1)})`; + if (r < 0.8) return `${source(depth + 1)}${random() < 0.5 ? "+" : "*"}${source(depth + 1)}`; + return `f${"xyz".slice(0, Math.floor(random() * 3))}(${source(depth + 1)},${source(depth + 1)})`; +} + +function parse(count: number): string { + let total = 0; + let failed = 0; + for (let i = 0; i < count; i++) { + // Every tenth input lacks its last character, which fails deep inside it. + const full = source(0); + text = i % 10 === 0 ? full.slice(0, -1) : full; + pos = 0; + try { + total = (total + expr()) % 1000003; + if (pos !== text.length) throw new ParseError(pos, "trailing input"); + } catch (error) { + if (!(error instanceof ParseError)) throw error; + failed++; + } + } + return `parsed ${count}: total ${total} failed ${failed}`; +} + +const scale = Number(process.argv[2] ?? "1"); +console.log(chains(Math.floor(60000 * scale))); +console.log(visits(Math.floor(60000 * scale))); +console.log(parse(Math.floor(40000 * scale))); diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index e017267d7..a6520b46e 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -58,7 +58,7 @@ The LLVM helper divides an executable's LLVM IR into partitions by its size. Up -The count depends only on the size of the LLVM text, so a program has the same partitions, and builds the same executable, on every host. A function's partition follows from its name, and a small internal function goes with the one function that calls it, so an edit leaves the other functions in their partitions. +The count depends only on the size of the LLVM text, so a program has the same partitions, and builds the same executable, on every host. In programs with 32 partitions or more, functions from the same source file are placed in the same partition, so that their calls to each other stay inside it; a file larger than 1 MiB of LLVM text or than an average partition is divided into parts by function name. Other functions, and the functions of smaller programs, are placed by their name, and a small internal function goes with the one function that calls it. An edit leaves almost every other function in its partition. These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler. diff --git a/docs/content/limitations/builtin-apis/assertions.mdx b/docs/content/limitations/builtin-apis/assertions.mdx index f7f5c9354..381fc3802 100644 --- a/docs/content/limitations/builtin-apis/assertions.mdx +++ b/docs/content/limitations/builtin-apis/assertions.mdx @@ -3,6 +3,8 @@ When `strictEqual` compares function values, `actual` and `expected` read as `undefined`; when `assert.throws` or `assert.rejects` reports an error that is not an instance of the expected class, or that does not match an expected object, `expected` reads as `undefined`. Values whose type can be cyclic, such as recursive records, also read as `undefined`. `assert.AssertionError` used as a value outside `instanceof` throws `SC2020`. + The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument. + `throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`. `assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused. `assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals. Checked-dynamic values compared deeply have no cycle detection. diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx index 89266b745..d783984fa 100644 --- a/docs/content/limitations/builtin-apis/date.mdx +++ b/docs/content/limitations/builtin-apis/date.mdx @@ -1,6 +1,7 @@ -- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported. - Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators. - Unions of a Date with other non-nullish types are unsupported. - Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile. +- **Date:** Spread constructor arguments and locale formatters are unsupported. + Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators. + Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them. + Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile. + Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint. A Date converted to `unknown` is the same Date object. See [Dates and timezones](#dates-and-timezones) for timezone data. diff --git a/docs/content/limitations/builtin-apis/prototypes.mdx b/docs/content/limitations/builtin-apis/prototypes.mdx index 170b11793..858de3c7e 100644 --- a/docs/content/limitations/builtin-apis/prototypes.mdx +++ b/docs/content/limitations/builtin-apis/prototypes.mdx @@ -1,8 +1,14 @@ - **Prototypes:** Named properties and methods on program class prototypes support replacement, inheritance, and deletion. - Bare prototype reflection, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported. + A module-level JavaScript class's prototype can become the prototype of other objects: `Object.create(C.prototype)`, `Object.setPrototypeOf(F.prototype, C.prototype)`, `Object.setPrototypeOf(object, C.prototype)`, and `util.inherits(F, C)` with an ordinary constructor `F`. + The inherited methods and accessors run with the inheriting object as `this`. + Methods that use `#private` members or `super` throw a TypeError for a receiver that is not an instance of the class. + `C.prototype` also reads as a value for `Object.getPrototypeOf` and for `===` and `!==` comparisons. + Changing the prototype of a class prototype (`Object.setPrototypeOf(C.prototype, X)`, `util.inherits(C, F)`, or `Object.setPrototypeOf` on a class prototype reached through an instance) is unsupported. + Other bare prototype reflection, TypeScript class prototypes as values, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported. Plain objects, arrays, functions, class instances, and Date, RegExp, Map, Set, WeakMap, WeakSet, and Promise values inherit `constructor`, `valueOf`, `toString`, `hasOwnProperty`, and `propertyIsEnumerable` from `Object.prototype` as in Node, so `value.constructor === Object` and `value.valueOf === Object.prototype.valueOf` compare identities exactly. Named reads such as `value.valueOf` answer these inherited members; computed-key reads such as `value[key]` do not. `Object.prototype`'s own members read as values; its mutation and other reflection, such as `isPrototypeOf` and `__proto__`, are unsupported. The constructors `Object`, `Function`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, and `Promise` read as function values; through a stored value only `Object` can be called. `new` through a stored `Object`, `Date`, `Map`, `Set`, `WeakMap`, or `WeakSet` builds the same value as the `new` expression. `new` through a stored `RegExp` or `Promise`, `new` of a stored `Object` with a primitive argument, and `instanceof` against these constructors are unsupported. + `instanceof Object` and `instanceof Function` on untyped values follow the prototype chain as in Node, so an object built with `Object.create(null)` is not an `Object`. diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index f4add0e6d..93b9af4a3 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -11,6 +11,7 @@ #include #include +#include using namespace llvm; @@ -36,6 +37,9 @@ struct Symbol { std::string PromotedHeader; std::vector Refs; std::vector Metadata; + // The source-module group the emitter wrote before this definition, or + // empty. + StringRef Group; unsigned Partition = NoPartition; }; @@ -70,6 +74,83 @@ uint32_t stableHash(StringRef Name) { return Hash; } +// Places the functions whose root carries a source-module group by group: a +// module's functions call each other most, so keeping them together leaves +// fewer calls for the thin link to import across partitions. A group larger +// than the mean partition, or than MaxChunkBytes, is divided into a power of +// two of chunks by the hash of each root's name, so the division changes only +// when the group's size doubles. Chunks are placed largest first, each in the +// lighter of two partitions chosen by hashes of its key, after the functions +// placed by name. The placement depends only on the module text and the +// partition count. Modules with fewer than MinGroupedPartitions partitions +// keep the placement by name: with few partitions per core, the balance of +// the largest partitions decides the build time more than the imports. +void placeGroups(std::vector &Symbols, ArrayRef Roots, + unsigned Count) { + constexpr unsigned MinGroupedPartitions = 32; + if (Count < MinGroupedPartitions) + return; + auto Bytes = [](const Symbol &S) -> uint64_t { + return S.Header.size() + S.Body.size(); + }; + std::vector Load(Count, 0); + uint64_t Total = 0; + StringMap GroupBytes; + for (unsigned I = 0; I < Symbols.size(); ++I) { + const Symbol &S = Symbols[I]; + if (S.Kind != SymbolKind::Function) + continue; + Total += Bytes(S); + StringRef Group = Symbols[Roots[I]].Group; + if (Group.empty()) + Load[S.Partition] += Bytes(S); + else + GroupBytes[Group] += Bytes(S); + } + if (GroupBytes.empty()) + return; + // A chunk is at most MaxChunkBytes, so the code a partition optimizes + // together, and the inlining within it, does not grow when a program has + // fewer partitions. + constexpr uint64_t MaxChunkBytes = 1024 * 1024; + uint64_t Chunk = + std::min(MaxChunkBytes, std::max(1, Total / Count)); + std::vector Keys(Symbols.size()); + StringMap KeyBytes; + for (unsigned I = 0; I < Symbols.size(); ++I) { + const Symbol &S = Symbols[I]; + StringRef Group = Symbols[Roots[I]].Group; + if (S.Kind != SymbolKind::Function || Group.empty()) + continue; + uint64_t Chunks = 1; + while (Chunks * Chunk < GroupBytes[Group]) + Chunks *= 2; + Keys[I] = Chunks == 1 + ? Group.str() + : (Group + "#" + + Twine(stableHash(Symbols[Roots[I]].Name) % Chunks)) + .str(); + KeyBytes[Keys[I]] += Bytes(S); + } + std::vector> Order; + for (const auto &Entry : KeyBytes) + Order.push_back({Entry.getKey(), Entry.getValue()}); + llvm::sort(Order, [](const auto &A, const auto &B) { + return A.second != B.second ? A.second > B.second : A.first < B.first; + }); + StringMap Where; + for (const auto &[Key, Size] : Order) { + unsigned First = stableHash(Key) % Count; + unsigned Second = stableHash((Key + "/2").str()) % Count; + unsigned Chosen = Load[Second] < Load[First] ? Second : First; + Load[Chosen] += Size; + Where[Key] = Chosen; + } + for (unsigned I = 0; I < Symbols.size(); ++I) + if (!Keys[I].empty()) + Symbols[I].Partition = Where[Keys[I]]; +} + bool startsWithLinkage(StringRef Rest) { for (StringRef Linkage : {"linkonce", "weak", "common", "appending", "extern_weak", @@ -326,6 +407,11 @@ std::optional partitionProgramText(StringRef Text, return true; }; + // The emitter writes "; scriptc-group " before the definitions of each + // source function; the group applies until the next such line, and an + // empty key ends it. + constexpr StringRef GroupLine = "; scriptc-group"; + StringRef Group; size_t Pos = 0; while (Pos < Text.size()) { size_t End = Text.find('\n', Pos); @@ -335,10 +421,15 @@ std::optional partitionProgramText(StringRef Text, size_t Next = End + 1; if (Line.empty() || Line.trim().empty()) { // Blank separator. + } else if (Line.starts_with(GroupLine) && + (Line.size() == GroupLine.size() || + Line[GroupLine.size()] == ' ')) { + Group = Line.drop_front(GroupLine.size()).trim(); } else if (Line.starts_with("define ")) { Symbol S; S.Kind = SymbolKind::Function; S.Header = Line; + S.Group = Group; if (!parseFunctionHeader(Line, S)) return std::nullopt; // The emitter indents every body line, so the first line that is @@ -466,6 +557,7 @@ std::optional partitionProgramText(StringRef Text, Referrer[Ref] = I; } } + std::vector Roots(Symbols.size()); for (unsigned I = 0; I < Symbols.size(); ++I) { if (Symbols[I].Kind != SymbolKind::Function) continue; @@ -479,8 +571,10 @@ std::optional partitionProgramText(StringRef Text, break; Root = Parent; } + Roots[I] = Root; Symbols[I].Partition = stableHash(Symbols[Root].Name) % Count; } + placeGroups(Symbols, Roots, Count); for (const Symbol &S : Symbols) { if (S.Kind != SymbolKind::Function) continue; diff --git a/native/llvm-codegen/src/partition.h b/native/llvm-codegen/src/partition.h index 838919443..35ef4c543 100644 --- a/native/llvm-codegen/src/partition.h +++ b/native/llvm-codegen/src/partition.h @@ -20,11 +20,15 @@ struct ProgramPartitions { }; // Divides the LLVM assembly scriptc emits into Count modules without parsing -// it, so the partitions can be parsed concurrently. Functions are placed by a -// stable hash of their name; an internal function referenced by exactly one -// other function stays with that function unless its body is too large to -// inline, and a global stays with the first function that references it. Returns nullopt for any construct outside the -// emitter's narrow top-level grammar; callers then split a parsed module. +// it, so the partitions can be parsed concurrently. In modules of 32 or +// more partitions, functions under a "; scriptc-group " line are placed +// with the other functions of that source-module group, which is divided +// into chunks when it is larger than the mean partition or 1 MiB; other +// functions are placed by a stable hash of their name. An internal function referenced by exactly one other function stays +// with that function unless its body is too large to inline, and a global +// stays with the first function that references it. Returns nullopt for any +// construct outside the emitter's narrow top-level grammar; callers then +// split a parsed module. std::optional partitionProgramText(llvm::StringRef Text, unsigned Count); diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz index b5f1e079f..31423fa04 100644 Binary files a/packages/compiler/native/cli.profdata.gz and b/packages/compiler/native/cli.profdata.gz differ diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index af53f74df..a54956ed9 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -21,6 +21,7 @@ import { import { type NativeArtifactDependency } from "./native/contracts.js"; import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js"; import type { NativeLinkPlan } from "./link-plan.js"; +import { lldLinkerArgs } from "./lld.js"; import { createDarwinDebugSymbols, installDarwinDebugSymbols, @@ -240,8 +241,11 @@ export async function linkNativeExecutable( try { const staged = await stageRuntimePackArtifacts(plan.runtimePack); stagedRoot = staged.root; + // Linux links with lld when the default driver finds it. + const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : []; const args = [ ...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []), + ...lld, ...plan.driverFlags, ...plan.inputs.map((input) => staged.replacements.get(input) ?? input), ...plan.systemLibraries.map((name) => `-l${name}`), @@ -276,7 +280,15 @@ export async function linkNativeExecutable( ...(await snapshotDependencies([...toolchain, ...additionalDependencyPaths])), ]) .catch(() => null); - await execFileAsync(linker, args); + await execFileAsync(linker, args).catch(async (error: unknown) => { + // A failed link with lld runs again with the driver's default linker, + // whose error (if any) is the one reported. + if (lld.length === 0) throw error; + await execFileAsync( + linker, + args.filter((arg) => !lld.includes(arg)), + ); + }); const output = await stat(privateOut); if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable"); if (plan.darwinDebugSymbols) { diff --git a/packages/compiler/src/backend/lld.test.ts b/packages/compiler/src/backend/lld.test.ts new file mode 100644 index 000000000..b0a466cf6 --- /dev/null +++ b/packages/compiler/src/backend/lld.test.ts @@ -0,0 +1,61 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { driverFindsLld, lldLinkerArgs } from "./lld.js"; + +const directories: string[] = []; +afterEach(() => { + for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +function tool(path: string): string { + writeFileSync(path, "#!/bin/sh\n"); + chmodSync(path, 0o755); + return path; +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "scriptc-lld-")); + directories.push(root); + const llvm = join(root, "llvm", "bin"); + const usr = join(root, "usr", "bin"); + const other = join(root, "other"); + for (const directory of [llvm, usr, other]) mkdirSync(directory, { recursive: true }); + return { root, llvm, usr, other }; +} + +test.skipIf(process.platform === "win32")( + "a driver finds ld.lld beside itself, beside its real executable, or on PATH", + () => { + const f = fixture(); + const clang = tool(join(f.llvm, "clang")); + // Without ld.lld anywhere. + expect(driverFindsLld(clang, { PATH: f.other })).toBe(false); + // Beside the driver. + tool(join(f.llvm, "ld.lld")); + expect(driverFindsLld(clang, { PATH: f.other })).toBe(true); + // A PATH entry that links to the driver: clang looks beside its real executable. + symlinkSync(clang, join(f.usr, "clang")); + expect(driverFindsLld("clang", { PATH: f.usr })).toBe(true); + // A driver elsewhere finds ld.lld on PATH, and not otherwise. + const gcc = tool(join(f.other, "gcc")); + expect(driverFindsLld(gcc, { PATH: f.other })).toBe(false); + expect(driverFindsLld(gcc, { PATH: [f.other, f.llvm].join(":") })).toBe(true); + expect(driverFindsLld("missing-driver", { PATH: f.llvm })).toBe(false); + }, +); + +test.skipIf(process.platform === "win32")( + "Linux links with the default driver use lld when the driver finds it", + () => { + const f = fixture(); + const clang = tool(join(f.llvm, "clang")); + tool(join(f.llvm, "ld.lld")); + const env = { PATH: f.other }; + expect(lldLinkerArgs("linux", clang, env)).toEqual(["-fuse-ld=lld"]); + expect(lldLinkerArgs("darwin", clang, env)).toEqual([]); + expect(lldLinkerArgs("windows", clang, env)).toEqual([]); + expect(lldLinkerArgs("linux", clang, { ...env, SCRIPTC_LINKER: clang })).toEqual([]); + }, +); diff --git a/packages/compiler/src/backend/lld.ts b/packages/compiler/src/backend/lld.ts new file mode 100644 index 000000000..790e07062 --- /dev/null +++ b/packages/compiler/src/backend/lld.ts @@ -0,0 +1,49 @@ +import { realpathSync, statSync } from "node:fs"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** The executable `command` names: a path, or the first match on PATH. */ +function commandFile(command: string, env: NodeJS.ProcessEnv): string | null { + if (command.includes("/")) return isFile(command) ? resolve(command) : null; + for (const directory of (env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) { + const candidate = join(directory || ".", command); + if (isFile(candidate)) return isAbsolute(candidate) ? candidate : resolve(candidate); + } + return null; +} + +/** + * Whether the compiler driver `driver` finds ld.lld for `-fuse-ld=lld`: in + * the directory of the driver's executable (where clang looks first) or on + * PATH. Linking still falls back to the driver's default linker when a link + * with lld fails. + */ +export function driverFindsLld(driver: string, env: NodeJS.ProcessEnv = process.env): boolean { + const file = commandFile(driver, env); + if (file === null) return false; + try { + for (const directory of new Set([dirname(file), dirname(realpathSync(file))])) + if (isFile(join(directory, "ld.lld"))) return true; + } catch { + return false; + } + return commandFile("ld.lld", env) !== null; +} + +/** `-fuse-ld=lld` for Linux links whose driver finds ld.lld. */ +export function lldLinkerArgs( + platform: string, + driver: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + return platform === "linux" && env["SCRIPTC_LINKER"] === undefined && driverFindsLld(driver, env) + ? ["-fuse-ld=lld"] + : []; +} diff --git a/packages/compiler/src/backend/llvm/blocks.ts b/packages/compiler/src/backend/llvm/blocks.ts index 354e631c6..eee4d00b9 100644 --- a/packages/compiler/src/backend/llvm/blocks.ts +++ b/packages/compiler/src/backend/llvm/blocks.ts @@ -9,6 +9,7 @@ * code after it); here the lines are DROPPED instead, and emitStmts skips * whole statements once the current block is terminated, so no dropped * definition can leak into a later block. */ +import { UNWIND_RESUME, UNWIND_SLOT } from "./unwind-abi.js"; export class BlockBuilder { private readonly blocks: { label: string; lines: string[]; term: string | null }[] = []; @@ -86,7 +87,18 @@ export class BlockBuilder { terminate(s: string): void { if (this.cur.term === null) { - if (this.returnEpilogue && s.startsWith("ret ")) this.line(this.returnEpilogue); + if ( + this.returnEpilogue && + (s.startsWith("ret ") || + s.startsWith("call void @scr_unwind_raise()") || + s === UNWIND_RESUME) + ) + this.line(this.returnEpilogue); + if (s === UNWIND_RESUME) { + const exception = this.tmp(); + this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`); + s = `resume { ptr, i32 } ${exception}`; + } this.cur.term = ` ${this.withDebugLocation(s)}`; } } diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index aef39ef75..0c0f1aaa0 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -580,7 +580,9 @@ test("the guard tests a string's ASCII bit and the fast copy reads bytes", () => const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; const guard = body.slice(0, body.search(/^bytes\.version\.slow\d*:/m)); expect(guard).toMatch(/load i32, ptr %\S+\n\s+%\S+ = icmp slt i32 %\S+, 0/); - const fast = body.slice(body.lastIndexOf("bytes.version.fast")); + // The byte copy, up to the units copy that follows it. + const units = body.search(/^bytes\.version\.fast\.units\d+:/m); + const fast = body.slice(body.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units); expect(fast).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/); expect(fast).not.toContain("@sc_str_char_code_at_int"); // The checked copy keeps the inline helper and its fallback. diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 1b51deee1..301f65de2 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -12,7 +12,13 @@ import { } from "./counted-loops.js"; import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js"; import { f64Lit } from "./common.js"; -import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js"; +import { + emitStringAsciiFlag, + emitStringByteLength, + STRING_CHAR_CODE_AT_DECL, + stringScanType, +} from "./string-reads.js"; +import { unitSafeLoop } from "./string-unit-versions.js"; type Node = IrExpr | IrStmt; type VarRef = IrExpr & { kind: "varRef" }; @@ -73,6 +79,14 @@ export interface BytesLoopVersion { receivers: { ref: VarRef; global: boolean; string: boolean }[]; /** Receivers the guard requires to have no shared storage. */ ownedReceivers: number[]; + /** The string reads among `bounds`. */ + stringReads: ReadonlySet; + /** The version's one string receiver, when the loop may also run over + * its UTF-16 units if it is not ASCII (string-unit-versions.ts). */ + unitsReceiver: number | null; + /** The units receiver's `length` reads in the loop. Both fast copies + * read the length the guard took (its UTF-16 length) in their place. */ + unitLengths: ReadonlySet; fixed: FixedCheck[]; affine: AffineCheck[]; /** While loops: accesses at a counter below a loop-invariant bound. */ @@ -114,6 +128,9 @@ export interface BytesLoopFacts { /** String bindings held as a slice of another string (string-slices.ts): * they have no string header for the guard to read. */ scalarStrings: ReadonlySet; + /** The functions a loop over a string's UTF-16 units may call; absent + * or null where emitted code does not read the runtime's scan receiver. */ + unitSafeFunctions?: ReadonlySet | null; } const MAX_COST = 400; @@ -670,6 +687,19 @@ export function matchBytesLoopVersion( // Renumber the receivers the kept checks use. const order = [...used].sort((a, b) => a - b); const renumber = new Map(order.map((old, index) => [old, index])); + const stringReads = new Set(); + for (const group of [...fixedKept, ...affineKept]) + if (receivers[group.check.receiver]!.string) + for (const node of group.nodes) stringReads.add(node); + for (const group of boundedKept) + if (receivers[group.check.receiver]!.string) + for (const { node } of group.nodes) stringReads.add(node); + const strings = order.filter((index) => receivers[index]!.string); + const unitLengths = + facts.unitSafeFunctions != null && strings.length === 1 + ? unitSafeLoop(loop, receivers[strings[0]!]!.ref.localId, facts.unitSafeFunctions) + : null; + const unitsReceiver = unitLengths !== null ? renumber.get(strings[0]!)! : null; const needsBound = affineKept.length > 0 && counted !== null && @@ -679,6 +709,9 @@ export function matchBytesLoopVersion( owned: ownedNodes, receivers: order.map((index) => receivers[index]!), ownedReceivers: ownedKept.map(([receiver]) => renumber.get(receiver)!), + stringReads, + unitsReceiver, + unitLengths: unitLengths ?? new Set(), fixed: fixedKept.map((g) => ({ receiver: renumber.get(g.check.receiver)!, end: g.check.end, @@ -1018,13 +1051,22 @@ function findWhileCounterAccesses( /** Branches to `fast` when every access the version proves is in bounds for * every iteration and every view it proves thread-owned has no shared * storage, and to `slow` otherwise (including a counted loop whose - * integer form is not established, or a module receiver not yet set). */ + * integer form is not established, or a module receiver not yet set). + * + * With `unitsFast`, the version's units receiver may instead be a string + * that is not ASCII: the guard has the runtime publish it as the scan + * receiver (one read), tests the same accesses against its UTF-16 length, + * and branches to `unitsFast`. It returns the units pointer that copy + * reads, which stays valid while the loop runs (string-unit-versions.ts), + * and for a version with a units receiver, the receiver's UTF-16 length + * on both fast paths (an i64). */ export function emitBytesLoopVersionGuard( host: LlvmEmitterContext, version: BytesLoopVersion, fast: string, slow: string, -): void { + unitsFast?: string, +): { units: string | null; length: string | null } | null { const B = host.B; const next = (cond: string): void => { if (cond === "true") return; @@ -1043,19 +1085,36 @@ export function emitBytesLoopVersionGuard( } return value; }); - const lengths = receivers.map((receiver, index) => { - const len = version.receivers[index]!.string - ? emitStringByteLength(host, receiver) - : emitBytesHeaderLength(host, receiver); + const unitsIndex = unitsFast !== undefined ? version.unitsReceiver : null; + const units: { read: { pointer: string; taken: string; published: string } | null } = { + read: null, + }; + const widen = (len: string): string => { if (host.sizeType === "i64") return len; const wide = B.tmp(); B.line(`${wide} = zext ${host.sizeType} ${len} to i64`); return wide; + }; + const lengths = receivers.map((receiver, index) => { + if (index === unitsIndex) { + const read = emitUnitsLength(host, receiver); + units.read = read; + return read.length; + } + return widen( + version.receivers[index]!.string + ? emitStringByteLength(host, receiver) + : emitBytesHeaderLength(host, receiver), + ); }); const conditions: string[] = []; + // The published count is the receiver's UTF-16 length only while the + // runtime publishes the receiver itself. + if (units.read) conditions.push(units.read.published); // A string's byte length is its UTF-16 length when it is ASCII. version.receivers.forEach(({ string }, index) => { - if (string) conditions.push(emitStringAsciiFlag(host, receivers[index]!)); + if (string && index !== unitsIndex) + conditions.push(emitStringAsciiFlag(host, receivers[index]!)); }); for (const index of version.ownedReceivers) { const owned = B.tmp(); @@ -1158,7 +1217,7 @@ export function emitBytesLoopVersionGuard( // Unreachable: every term has a proven range. Stay checked. B.br(slow); B.startBlock(B.newLabel("bytes.version.dead")); - return; + return null; } const wide = widenInteger(host, integer); const sum = B.tmp(); @@ -1215,8 +1274,70 @@ export function emitBytesLoopVersionGuard( valid = all; } } - if (valid === "true") B.br(fast); - else B.condBr(valid, fast, slow); + const length = version.unitsReceiver !== null ? lengths[version.unitsReceiver]! : null; + const taken = units.read; + const target = taken ? B.newLabel("bytes.version.units") : fast; + if (valid === "true") B.br(target); + else B.condBr(valid, target, slow); + if (!taken) return { units: null, length }; + B.startBlock(target); + B.condBr(taken.taken, unitsFast!, fast); + return { units: taken.pointer, length }; +} + +/** The length of a version's units receiver `s` in UTF-16 units: its byte + * length when it is ASCII; otherwise, after one runtime read publishes it + * as the scan receiver, the published unit count (0 when the runtime does + * not hold its units). Also the units pointer, whether the loop is to + * read it, and whether the published receiver is `s`. */ +function emitUnitsLength( + host: LlvmEmitterContext, + s: string, +): { length: string; pointer: string; taken: string; published: string } { + const B = host.B; + const sz = host.sizeType; + const type = stringScanType(sz); + const ascii = emitStringAsciiFlag(host, s); + const bytes = emitStringByteLength(host, s); + const from = B.currentLabel(); + const publish = B.newLabel("bytes.version.publish"), + join = B.newLabel("bytes.version.length"); + B.condBr(ascii, join, publish); + B.startBlock(publish); + host.declare(STRING_CHAR_CODE_AT_DECL); + const read = B.tmp(), + receiver = B.tmp(), + same = B.tmp(), + pointerSlot = B.tmp(), + pointer = B.tmp(), + countSlot = B.tmp(), + count = B.tmp(), + published = B.tmp(); + B.line(`${read} = call double @scr_str_char_code_at(ptr ${s}, double 0.0)`); + B.line(`${receiver} = load ptr, ptr @scr_str_scan`); + B.line(`${same} = icmp eq ptr ${receiver}, ${s}`); + B.line(`${pointerSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 1`); + B.line(`${pointer} = load ptr, ptr ${pointerSlot}`); + B.line(`${countSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 2`); + B.line(`${count} = load ${sz}, ptr ${countSlot}`); + B.line(`${published} = select i1 ${same}, ${sz} ${count}, ${sz} 0`); + const end = B.currentLabel(); + B.br(join); + B.startBlock(join); + const length = B.tmp(), + units = B.tmp(), + taken = B.tmp(); + B.line(`${length} = phi ${sz} [ ${bytes}, %${from} ], [ ${published}, %${end} ]`); + B.line(`${units} = phi ptr [ null, %${from} ], [ ${pointer}, %${end} ]`); + B.line(`${taken} = phi i1 [ false, %${from} ], [ true, %${end} ]`); + const exact = B.tmp(); + B.line(`${exact} = phi i1 [ true, %${from} ], [ ${same}, %${end} ]`); + let wide = length; + if (sz !== "i64") { + wide = B.tmp(); + B.line(`${wide} = zext ${sz} ${length} to i64`); + } + return { length: wide, pointer: units, taken, published: exact }; } function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string { diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 1ff4c6580..bb402d5b8 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -50,8 +50,8 @@ export function emitCountedLoopLimit( } /** The run-time test of a guarded counted loop: an unproven limit is within - * the integer form's finite range and an unproven start is an exact safe - * integer. Returns `true` when nothing needs testing. */ + * the integer form's finite range and an unproven start is an exact integer + * within the loop's start bound. Returns `true` when nothing needs testing. */ export function emitCountedLoopSafety( host: LlvmEmitterContext, loop: IntegerCountedForLoop, @@ -65,7 +65,11 @@ export function emitCountedLoopSafety( `${safe} = fcmp ${loop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(loop.guardLimit)}`, ); } - if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start)); + if (loop.startGuarded) { + const start = host.emitExpr(loop.start); + const bound = loop.startBound; + safe = andSafe(host, safe, emitExactIntegerTest(host, start.name, -bound, bound).ok); + } for (const follower of loop.followers) { if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start)); if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower)); diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts index a88c38575..9ec2d5d39 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -122,6 +122,7 @@ const counted = ( guarded: false, limitGuarded: false, startGuarded: false, + startBound: Number.MAX_SAFE_INTEGER, guardLimit: 0, followers: [], ...fields, diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index da31ffdbb..c7b80b71d 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -43,7 +43,7 @@ import { isDynTypedRefType, isRefCounted, isUnitType, - recordTextCodecClass, + recordHandleCodecClass, textCodecRecordClass, typeKey, } from "../../ir/ir.js"; @@ -98,14 +98,16 @@ export const DYN_HANDLE_TAG_NUM: Record = { /** The handle tags of TextEncoder and TextDecoder records boxed as dyn * (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */ -export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = { +export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder" | "StringDecoder", number> = { TextDecoder: 76, TextEncoder: 77, + StringDecoder: 78, }; /** The decoder record fields scr_text_decoder_to_dyn reads, in its * parameter order. */ const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const; +const STRING_DECODER_FIELDS = ["%enc", "%pending"] as const; /** Handle kinds whose runtime unit boxes them through its own function, * which installs the tag's ops on first use (scr_crypto.h). Programs that @@ -1825,11 +1827,18 @@ export class LlDyn { const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id)); if (codec !== null) { // The record the codec's handle box holds (+1), or the path TypeError. - host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`); const checked = B.tmp(); - B.line( - `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`, - ); + if (codec === "StringDecoder") { + host.declare(`declare ptr @scr_string_decoder_from_dyn(ptr, ptr, ptr)`); + B.line( + `${checked} = call ptr @scr_string_decoder_from_dyn(ptr %d, ptr %path, ptr ${want})`, + ); + } else { + host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`); + B.line( + `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`, + ); + } B.terminate(`ret ptr ${checked}`); this.defs.push( `define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`, @@ -2917,14 +2926,14 @@ export class LlDyn { return matched; } - /** A TextEncoder or TextDecoder record boxes as its handle (+1), which - * holds the record by reference. A decoder passes the byte offsets of the - * fields its boxed decode reads. */ + /** A TextEncoder, TextDecoder or StringDecoder record boxes as its + * handle (+1), which holds the record by reference. The decoders pass + * the byte offsets of the fields their boxed methods read. */ private textCodecToDyn( B: BlockBuilder, t: IrType & { kind: "record" }, shape: IrRecordShape, - codec: "TextEncoder" | "TextDecoder", + codec: "TextEncoder" | "TextDecoder" | "StringDecoder", ): void { const host = this.host; const rc = vAdapters(host, t); @@ -2939,10 +2948,12 @@ export class LlDyn { const base = host.nullableUnions.fieldBase(t.shapeId); const start = B.tmp(); B.line(`${start} = ptrtoint ptr %v to ${this.S}`); - const offsets = TEXT_DECODER_FIELDS.map((field) => { + const fields: readonly string[] = + codec === "StringDecoder" ? STRING_DECODER_FIELDS : TEXT_DECODER_FIELDS; + const offsets = fields.map((field) => { const index = shape.fields.findIndex((f) => f.name === field); if (index < 0) - throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`); + throw new InternalCompilerError(`llvm emitter bug: ${codec} record lacks ${field}`); const p = B.tmp(); const at = B.tmp(); const offset = B.tmp(); @@ -2953,11 +2964,11 @@ export class LlDyn { B.line(`${offset} = sub ${this.S} ${at}, ${start}`); return `${this.S} ${offset}`; }); - host.declare( - `declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`, - ); + const fn = + codec === "StringDecoder" ? "scr_string_decoder_to_dyn" : "scr_text_decoder_to_dyn"; + host.declare(`declare ptr @${fn}(ptr, ptr, ptr, ${fields.map(() => this.S).join(", ")})`); B.line( - `${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`, + `${r} = call ptr @${fn}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`, ); } B.terminate(`ret ptr ${r}`); @@ -3137,7 +3148,7 @@ export class LlDyn { const shape = host.recordsById.get(t.shapeId); if (!shape) throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`); - const codec = recordTextCodecClass(shape); + const codec = recordHandleCodecClass(shape); if (codec !== null) { this.textCodecToDyn(B, t, shape, codec); break; diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 8f306035a..a3ebbe0e8 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -56,11 +56,14 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js"; import { beginFunctionLoops, LOOPS } from "./loop-state.js"; import { runningExtremumUpdates } from "./running-extrema.js"; import { findIntegerOrNaNLocals } from "./wrapping-sums.js"; +import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js"; export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const B = new BlockBuilder(); emitter.B = B; emitter.currentFunctionName = fn.name; + emitter.currentUnwindAbi = emitter.unwindAbi.has(fn.name); + emitter.usesLandingPads = false; emitter.findInt32Allocations(fn); emitter.currentTier = fn.tier; // Fiber switches restore the active exception cell before control @@ -136,6 +139,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator; beginFunctionLoops(emitter, fn, numericFn); emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled; + emitter.loopCopiesEnabled = true; emitter.speculativeFunction = fn.name; emitter.speculation = null; emitter.speculationResume = null; @@ -159,6 +163,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.optionalArrayReads, emitter.referenceWrites, emitter.fnByName, + emitter.nodeIndex, ) : new Set(); emitter.localArrayReads = findLocalArrayReads( @@ -324,7 +329,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const paramIds = new Set(fn.params.map((p) => p.localId)); if (emitter.debug === null) { - for (const id of findIntegerViews(numericFn)) { + for (const id of findIntegerViews(numericFn, emitter.nodeIndex)) { // An i32 slot is its own view. if (emitter.int32Locals.has(id)) continue; const slot = B.slot(); @@ -595,6 +600,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { B.terminate(cleanup.terminator); } + if (emitter.usesLandingPads && emitter.currentUnwindAbi) + B.entryAllocas.push(`${UNWIND_SLOT} = alloca { ptr, i32 }`); if (emitter.logArgSlots > 0) { B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`); } @@ -635,7 +642,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`; const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); emitter.privateGlobalSlots.clear(); - let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; + // Landing pads (emitDirectCall) need the personality. + const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : ""; + if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`); + let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`; if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi); if (emitter.integerClones.members.has(fn.name)) { body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true); @@ -1307,8 +1317,10 @@ export function emitArgPackAndTrampolinePrologue( * code. */ export function parkingFunctions(emitter: LlEmitter): Set | null { if (emitter.wasi || emitter.mod.workers === true) return null; - for (const decl of emitter.decls) if (/@scr_test_sub\b/.test(decl)) return null; - for (const text of emitter.fnDefText.values()) if (/@scr_test_sub\b/.test(text)) return null; + const testSub = (text: string): boolean => + text.includes("@scr_test_sub") && /@scr_test_sub\b/.test(text); + for (const decl of emitter.decls) if (testSub(decl)) return null; + for (const text of emitter.fnDefText.values()) if (testSub(text)) return null; // Reverse mention edges, then propagate "may park" to every mentioner. const mentionedBy = new Map>(); const parks = new Set(); diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 7f4ac73b6..554e6a5a1 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -61,10 +61,11 @@ import { hasLoneSurrogate, wtf8Bytes } from "../literals.js"; import { undefinedArmTag } from "../../ir/analysis.js"; import { NULLABLE_ABSENT, NULLABLE_NULL } from "./nullable-unions.js"; import { literalEqualityDefinition } from "./string-literal-equality.js"; -import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js"; +import { STRING_READ_HELPERS, stringScanDecl, stringScanInline } from "./string-reads.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; import { InternalCompilerError } from "../../errors.js"; import { targetCpuAttribute } from "../target-cpu.js"; +import type { IrFunction } from "../../ir/ir.js"; /** The function bodies emitted in `chunks` contiguous ranges, each by its * own emitter (LlEmitter.chunkEmitter), then merged in function order: the @@ -206,6 +207,26 @@ function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean) ]; } +/** The group line written before each function's text. Functions from one + * source file share a group, which the LLVM helper keeps in the same + * partitions so that their calls to each other stay inside a partition + * (native/llvm-codegen/src/partition.h). A group is numbered by the first + * function of its file in the module's function order, which does not depend + * on the host. Functions without a source file get no group. */ +function partitionGroupLines(functions: readonly IrFunction[]): string[] { + const groups = new Map(); + return functions.map((fn) => { + const file = fn.loc.file; + if (file === "") return PARTITION_GROUP_END; + let group = groups.get(file); + if (group === undefined) groups.set(file, (group = groups.size)); + return `${PARTITION_GROUP_END} ${group}`; + }); +} + +/** Ends the group of the definitions that follow. */ +const PARTITION_GROUP_END = "; scriptc-group"; + export function emitModuleParts(emitter: LlEmitter): string[] { // Function bodies first (the literal/unit/fn-value tables fill as they // emit), then the file assembles around them — the runtime ABI’s order. @@ -915,9 +936,13 @@ export function emitModuleParts(emitter: LlEmitter): string[] { for (const line of wrappers) out.push(line); for (const line of asyncDefs) out.push(line); for (const line of emitter.resolveThunkDefs) out.push(line); - out.push(fnDefs[0] ?? ""); - for (let i = 1; i < fnDefs.length; i++) out.push("", fnDefs[i]!); - out.push(""); + const groups = partitionGroupLines(emitter.mod.functions); + for (let i = 0; i < fnDefs.length; i++) { + if (i > 0) out.push(""); + out.push(groups[i] ?? PARTITION_GROUP_END, fnDefs[i]!); + } + if (fnDefs.length === 0) out.push(""); + out.push(PARTITION_GROUP_END, ""); // WebAssembly modules translated into this program, and the registry. for (const line of emitWasmModules(emitter.mod, flushedDecls, emitter.wasmBoundsChecks)) out.push(line); @@ -1590,9 +1615,7 @@ export function helperDefs(emitter: LlEmitter): string[] { // A scan's receiver reads inline too (ScrStrScan): from ordinary global // storage, or, in worker executables, from the thread's static TLS block, // one thread-pointer-relative load (see executableTls). - const scanInline = - emitter.executableTls || - (emitter.mod.workers !== true && emitter.mod.lib?.threadInstances !== true); + const scanInline = stringScanInline(emitter); let scanDeclared = false; for (const helper of STRING_READ_HELPERS) { if (!emitter.decls.has(helper.decl)) continue; diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 55e31219c..9b758e834 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -994,7 +994,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { LOOPS.of(emitter).denseLoopsEnabled = false; try { B.startBlock(slow); - emitter.emitStmt(s); + emitSlowCopy(emitter, s); B.br(done); B.startBlock(fast); LOOPS.of(emitter).denseLoop = dense; @@ -1018,7 +1018,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { LOOPS.of(emitter).byteWindowsEnabled = false; try { B.startBlock(slow); - emitter.emitStmt(s); + emitSlowCopy(emitter, s); B.br(done); B.startBlock(fast); emitter.integerRanges = window.ranges; @@ -1072,7 +1072,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.condBr(safe, fast, slow); B.startBlock(slow); LOOPS.of(emitter).countedLoopsEnabled = false; - emitter.emitStmt(s); + emitSlowCopy(emitter, s); LOOPS.of(emitter).countedLoopsEnabled = true; B.br(countedJoin); B.startBlock(fast); @@ -2049,7 +2049,7 @@ function emitCallbackVersionedLoop( emitter.callbackVersioning.add(s); try { B.startBlock(slow); - emitter.emitStmt(s); + emitSlowCopy(emitter, s); B.br(done); B.startBlock(fast); for (const { ref } of bindings) emitter.knownCallbacks.add(ref.localId); @@ -2062,6 +2062,20 @@ function emitCallbackVersionedLoop( B.startBlock(done); } +/** The copy of a versioned loop that runs when its guard fails. Its loops + * get no speculative or int32 kind copies, which would emit each loop again + * for a path that rarely runs. Proven integer loops, which emit one copy, + * are kept. */ +function emitSlowCopy(emitter: LlEmitter, s: IrStmt): void { + const copies = emitter.loopCopiesEnabled; + emitter.loopCopiesEnabled = false; + try { + emitter.emitStmt(s); + } finally { + emitter.loopCopiesEnabled = copies; + } +} + /** Entries in a row whose fast copy bailed before a speculative loop site * stops trying it. */ const SPECULATION_BAILS = 8; @@ -2082,28 +2096,56 @@ function emitVersionedLoop( const fast = B.newLabel("bytes.version.fast"), slow = B.newLabel("bytes.version.slow"), done = B.newLabel("bytes.version.done"); - emitBytesLoopVersionGuard(emitter, version, fast, slow); + const unitsFast = + version.unitsReceiver !== null ? B.newLabel("bytes.version.fast.units") : undefined; + const guard = emitBytesLoopVersionGuard(emitter, version, fast, slow, unitsFast); + const units = guard?.units ?? null; const originalBounds = emitter.bytesBounds, originalOwned = emitter.bytesOwned, - originalRanges = emitter.integerRanges; + originalRanges = emitter.integerRanges, + originalUnitReads = emitter.stringUnitReads, + originalLengthReads = emitter.stringLengthReads; LOOPS.of(emitter).bytesLoopVersioning.add(s); try { B.startBlock(slow); - emitter.emitStmt(s); + emitSlowCopy(emitter, s); B.br(done); - B.startBlock(fast); - emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]); if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]); if (version.ranges.size > 0) emitter.integerRanges = new Map([...originalRanges, ...version.ranges]); if (version.counted?.guarded || version.paired?.loop.guarded) LOOPS.of(emitter).provenCountedGuards.add(s); + B.startBlock(fast); + emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]); + // Both fast copies read the string's length the guard took. + const length = guard?.length; + if (length != null && version.unitLengths.size > 0) + emitter.stringLengthReads = new Map([ + ...originalLengthReads, + ...[...version.unitLengths].map((node) => [node, length] as const), + ]); emitter.emitStmt(s); B.br(done); + if (unitsFast !== undefined && units !== null) { + // The string reads take the receiver's UTF-16 units. + B.startBlock(unitsFast); + emitter.bytesBounds = new Set([ + ...originalBounds, + ...[...version.bounds].filter((node) => !version.stringReads.has(node)), + ]); + emitter.stringUnitReads = new Map([ + ...originalUnitReads, + ...[...version.stringReads].map((node) => [node, units] as const), + ]); + emitter.emitStmt(s); + B.br(done); + } } finally { emitter.bytesBounds = originalBounds; emitter.bytesOwned = originalOwned; emitter.integerRanges = originalRanges; + emitter.stringUnitReads = originalUnitReads; + emitter.stringLengthReads = originalLengthReads; LOOPS.of(emitter).provenCountedGuards.delete(s); LOOPS.of(emitter).bytesLoopVersioning.delete(s); } @@ -2120,6 +2162,7 @@ export function emitLoopCopies( const plan = allowed && emitter.speculativeLoopsEnabled && + emitter.loopCopiesEnabled && emitter.speculation === null && emitter.speculationResume === null ? planSpeculativeLoop( @@ -2147,6 +2190,7 @@ export function emitLoopCopies( const kinds = allowed && emitter.speculativeLoopsEnabled && + emitter.loopCopiesEnabled && emitter.int32Slots.checkedInt32Arrays.size > 0 && emitter.int32Kinds === null && emitter.speculation === null && @@ -2437,7 +2481,7 @@ function planProvenLoop( loop: Extract, ): ProvenLoopPlan | null { if ( - loop.kind !== "for" || + loop.kind === "doWhile" || !emitter.speculativeLoopsEnabled || emitter.speculation !== null || emitter.speculationResume !== null || diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6760f3b74..0243763f1 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -80,6 +80,7 @@ import { } from "./emit-journal.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { versionLoopBoundParameters } from "../../ir/parameter-versions.js"; +import { inlineExpressionFunctions } from "../../ir/expression-inlining.js"; import { allocatedLiteral, fillAllocationLength, @@ -97,13 +98,20 @@ import { unshareNodes } from "../../ir/unshare.js"; import { runPairedWhileLoopsAsFor } from "../../ir/paired-while-loops.js"; import { copyStableCaptures } from "../../ir/capture-values.js"; import { resolveSelfCaptures } from "../../ir/self-captures.js"; -import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js"; +import { + analyzeInt32Slots, + dynamicFieldStores, + type DynamicFieldStores, + type Int32Slots, +} from "../../ir/int32-slots.js"; import { analyzeFinalFields, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { dropUnwrittenPropertyBags } from "../../ir/property-bags.js"; import { freezeInitOnlyGlobals } from "../../ir/init-only-globals.js"; import { hoistFinalFieldReads } from "../../ir/field-values.js"; import { holdCharLocalsAsUnits } from "../../ir/char-unit-locals.js"; +import { unitSafeFunctions } from "./string-unit-versions.js"; +import { stringScanInline } from "./string-reads.js"; import { findInt32Abis, INT32_ABI_SUFFIX, @@ -123,6 +131,16 @@ import { type FfiCallbackAdapter, } from "../ffi-callbacks.js"; import { computeMayThrow } from "../may-throw.js"; +import { + UNWIND_PERSONALITY, + UNWIND_RAISE, + UNWIND_RAISE_DECLARATION, + UNWIND_RESUME, + UNWIND_SLOT, + UnwindAbiViolation, + bridgePlainUnwindCalls, + unwindAbiFunctions, +} from "./unwind-abi.js"; import { mangleAsyncInlineAwaitCall, mangleAsyncSpawn, @@ -171,7 +189,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; -import { IrNodeIndex } from "../../ir/node-index.js"; +import { eachNodeOfKinds, IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; import { lastUseRead } from "./local-extents.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; @@ -434,6 +452,12 @@ export interface ForkPrimitives { export interface LlvmTargetOptions { /** C ABI target; omitted direct emission uses the host ABI. */ targetTriple?: string; + /** Linux (glibc and musl) executables: functions reached only by direct + * calls propagate exceptions by unwinding the native stack (unwind-abi.ts). */ + unwindAbi?: boolean; + /** Functions kept on the pending-flag ABI under `unwindAbi`: the ones an + * earlier emission found breaking its rules (UnwindAbiViolation). */ + unwindExcluded?: ReadonlySet; /** Emit the function bodies in this many contiguous ranges, each by its * own emitter, and merge them in function order (emit-journal.ts). The * output is the same as one emitter's. Debug builds (`debugSources`) @@ -506,14 +530,24 @@ function versionParameters(mod: IrModule): IrModule { return versionLoopBoundParameters(mod, mangleFunction); } -/** The int32 arrays the int32 slot analysis keeps in `mod` (proven and - * checked), under the inputs LlEmitter gives it (selectInt32Arrays). */ -function provenInt32Arrays(mod: IrModule): ReadonlySet { - const slots = analyzeInt32Slots(mod, { - dynamicStores: dynamicFieldStores(mod), - initializerBindings: findInitializerBindings(mod), - }); - return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]); +/** The int32 slot analysis of one module and the inputs it read: the + * constructor's own analyses of that module, which LlEmitter takes over + * instead of repeating them when it emits the same module. */ +export interface Int32SlotAnalysis { + readonly mod: IrModule; + readonly nodeIndex: IrNodeIndex; + readonly initializerBindings: ReturnType; + readonly dynamicStores: DynamicFieldStores; + readonly slots: Int32Slots; +} + +/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. */ +function analyzeModuleInt32Slots(mod: IrModule): Int32SlotAnalysis { + const nodeIndex = new IrNodeIndex(mod); + const initializerBindings = findInitializerBindings(mod, nodeIndex); + const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex); + const slots = analyzeInt32Slots(mod, { dynamicStores, initializerBindings, nodes: nodeIndex }); + return { mod, nodeIndex, initializerBindings, dynamicStores, slots }; } /** The functions whose calls the backend may emit as calls of their @@ -530,8 +564,16 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. const mod = unshareNodes(input); + // Each round of selectInt32Arrays analyzes the module it marks; the + // emitter of the module it returns reuses that round's analysis. + const rounds: { last: Int32SlotAnalysis | null } = { last: null }; + const proven = (marked: IrModule): ReadonlySet => { + const analysis = analyzeModuleInt32Slots(marked); + rounds.last = analysis; + return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]); + }; // Keep source storage intact for debugger inspection in dev builds. - return new LlEmitter( + const emitted = options.debugSources === undefined ? selectInt32Arrays( versionParameters( @@ -539,13 +581,15 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { scalarizeNumericRecords( specializeCallbackCalls( specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + ), ), ), ), @@ -559,24 +603,44 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { ), ), ), - provenInt32Arrays, + proven, resultEntryTargets, ) - : mod, - options, - ); + : mod; + const prepared = rounds.last; + return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null); +} + +/** Emits the module's parts, emitting it again with the functions an + * UnwindAbiViolation names on the pending-flag ABI. */ +function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] { + for (let excluded = options.unwindExcluded ?? new Set(); ;) { + const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded }); + try { + return emitter.emitParts(); + } catch (error) { + if (!(error instanceof UnwindAbiViolation)) throw error; + const names = new Set(excluded); + for (const name of emitter.unwindAbi) + for (const symbol of [mangleFunction(name), mangleBorrowedFunction(name)]) + if (error.symbols.has(symbol) || error.symbols.has(symbol + INT32_ABI_SUFFIX)) + names.add(name); + if (names.size === excluded.size) throw error; + excluded = names; + } + } } export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { - return moduleEmitter(input, options).emit(); + return emitModuleText(input, options).join("\n"); } /** emitLlvmModule's text as parts that concatenate to it, without the copy * that joining them makes: a large program's text is 150 MB. */ export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] { - return moduleEmitter(input, options) - .emitParts() - .flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); + return emitModuleText(input, options).flatMap((part, index) => + index === 0 ? [part] : ["\n", part], + ); } /** Keep large translation units below the host's single-string limit. */ @@ -586,7 +650,7 @@ export function emitLlvmModuleSource( ): string | readonly string[] { // IR validation, contracts and the frontend's disposal run after lowering without checks. relieveHeapPressure(true); - const parts = moduleEmitter(input, options).emitParts(); + const parts = emitModuleText(input, options); const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1)); if (length <= 256 * 1024 * 1024) return parts.join("\n"); return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); @@ -598,6 +662,21 @@ export function llStrBytes(text: string): string { const NO_BORROWED: ReadonlySet = new Set(); +/** The nodes that write a binding by its id (numberGlobalWriters). */ +const GLOBAL_WRITE_KINDS: readonly IrNodeKind[] = ["assign", "varDecl", "assignExpr", "incDec"]; +/** Runtime library calls (readsLoopMetrics). */ +const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"]; + +/** [A-Za-z0-9_] */ +function isIdentifierByte(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 95 + ); +} + /** Runtime entry points (and await-call entries) that may park the running * execution context, as they appear in emitted function text. */ export const SUSPENDS = @@ -1215,6 +1294,10 @@ export class LlEmitter { /** May-throw analysis (computeMayThrow): pending * checks are emitted only after calls that can actually raise. */ readonly mayThrow: Set; + /** The functions that propagate exceptions by unwinding (unwind-abi.ts). */ + readonly unwindAbi: Set; + /** Whether the function being emitted is one of unwindAbi. */ + currentUnwindAbi = false; readonly indirectMayThrow: boolean; /** Worker programs: the functions that poll for termination on entry * (workerTerminationPolls); null outside worker programs. */ @@ -1423,6 +1506,9 @@ export class LlEmitter { integerTempsEnabled = false; /** Speculative integer loops (speculative-integers.ts) run in this function. */ speculativeLoopsEnabled = false; + /** False inside the copy of a versioned loop that runs when its guard + * fails: loops there get no speculative or int32 kind copies. */ + loopCopiesEnabled = true; /** The fast copy of a speculative integer loop being emitted: its * integer-computed writes and where each resumes in the ordinary copy. */ speculation: { @@ -1477,6 +1563,12 @@ export class LlEmitter { * (one-unit tests and switches, charAtUnitRead), with the charAt each * stands for: a loop version's proof for the charAt covers its read. */ readonly charAtUnitReads = new Map(); + /** String reads a loop version proved in bounds of a string's UTF-16 + * units, with the units pointer to read (string-unit-versions.ts). */ + stringUnitReads: ReadonlyMap = new Map(); + /** `length` reads of a loop version's string, with the i64 UTF-16 + * length its guard took (bytes-loop-versions.ts). */ + stringLengthReads: ReadonlyMap = new Map(); /** Worker programs: typed-array accesses whose views an enclosing version * guard proved thread-owned. */ bytesOwned: ReadonlySet = new Set(); @@ -1562,6 +1654,7 @@ export class LlEmitter { readonly mod: IrModule, options: LlvmTargetOptions, journal: EmitJournal | null = null, + int32Analysis: Int32SlotAnalysis | null = null, ) { this.targetOptions = options; this.journal = journal; @@ -1572,7 +1665,7 @@ export class LlEmitter { options.debugSources === undefined); // One walk of every body, which the analyses below read instead of // walking the bodies themselves. - this.nodeIndex = new IrNodeIndex(mod); + this.nodeIndex = int32Analysis?.nodeIndex ?? new IrNodeIndex(mod); this.stackTraces = observesStacks(mod, this.nodeIndex); this.nullableUnions = new NullableUnions( mod, @@ -1591,14 +1684,18 @@ export class LlEmitter { this.nullableUnions.armTypes(), ); this.constantNumericTables = findConstantNumericTables(mod); - this.initializerBindings = findInitializerBindings(mod, this.nodeIndex); + this.initializerBindings = + int32Analysis?.initializerBindings ?? findInitializerBindings(mod, this.nodeIndex); // One walk finds the dynamic field stores every analysis below consults. - const dynamicStores = dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); - this.int32Slots = analyzeInt32Slots(mod, { - dynamicStores, - initializerBindings: this.initializerBindings, - nodes: this.nodeIndex, - }); + const dynamicStores = + int32Analysis?.dynamicStores ?? dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); + this.int32Slots = + int32Analysis?.slots ?? + analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings: this.initializerBindings, + nodes: this.nodeIndex, + }); this.liveViewsReadOnly = mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0; this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map(); @@ -1668,6 +1765,16 @@ export class LlEmitter { nodes: this.nodeIndex, }); this.mayThrow = mt.fns; + this.unwindAbi = + options.unwindAbi === true && + !this.wasi && + mod.lib === undefined && + options.debugSources === undefined && + (options.targetTriple + ? /-linux-(?:gnu|musl)/.test(options.targetTriple) + : process.platform === "linux") + ? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded) + : new Set(); this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; // A stackless call's eager prefix borrows its caller's stack; the @@ -1803,10 +1910,13 @@ export class LlEmitter { : undefined, ) : NO_INTEGER_CLONES; + // Integer clones and their dispatchers call each other through their + // own lowering, which keeps plain calls and flag tests. + for (const name of this.integerClones.members) this.unwindAbi.delete(name); this.stackCallbacks = new StackCallbacks(this.fnByName); - this.receiverReaders = new AmbientReceiverReaders(this.fnByName); + this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex); this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks); - this.lazyCaptures = new LazyCaptures(this.fnByName); + this.lazyCaptures = new LazyCaptures(this.fnByName, this.nodeIndex); const traced = computeTraced(mod, dynamicStores, cycleMutability); this.tracedShapes = traced.shapes; this.tracedUnions = traced.unions; @@ -2082,7 +2192,8 @@ export class LlEmitter { return this.emitParts().join("\n"); } emitParts(): string[] { - return emitParts(this); + const parts = emitParts(this); + return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts; } /** The dispose-observer gates the emitted frees and record writers test @@ -2235,9 +2346,23 @@ export class LlEmitter { this.mangledFunctions.set(mangleBorrowedFunction(fn.name), fn.name); } } - for (const match of text.matchAll(/@(sc_b?f_[A-Za-z0-9_]+)/g)) { - const callee = this.mangledFunctions.get(match[1]!); + // The names `@(sc_b?f_[A-Za-z0-9_]+)` matches, found with indexOf. + for (let at = text.indexOf("@sc_"); at >= 0;) { + let start = at + 4; + if (text.charCodeAt(start) === 98 /* b */) start++; + if (text.charCodeAt(start) !== 102 /* f */ || text.charCodeAt(start + 1) !== 95 /* _ */) { + at = text.indexOf("@sc_", at + 1); + continue; + } + let end = start + 2; + while (end < text.length && isIdentifierByte(text.charCodeAt(end))) end++; + if (end === start + 2) { + at = text.indexOf("@sc_", at + 1); + continue; + } + const callee = this.mangledFunctions.get(text.slice(at + 1, end)); if (callee !== undefined) yield callee; + at = text.indexOf("@sc_", end); } } @@ -3178,6 +3303,13 @@ export class LlEmitter { terminator = `br label %${target.label}`; } else if (this.currentCoro !== null) { terminator = `br label %${this.currentCoro.finalLabel}`; + } else if (this.currentUnwindAbi && this.inLandingPad) { + // The unwind that entered the landing pad goes on to the caller's. + terminator = UNWIND_RESUME; + } else if (this.currentUnwindAbi) { + // The caller's invoke takes the exception at its landing pad. + this.declare(UNWIND_RAISE_DECLARATION); + terminator = UNWIND_RAISE; } else { const t = this.llType(this.currentReturnType); terminator = @@ -3208,6 +3340,7 @@ export class LlEmitter { passThrough !== undefined && !target && this.currentCoro === null && + !this.currentUnwindAbi && !this.currentInt32Result ) { const t = this.llType(this.currentReturnType); @@ -3394,6 +3527,46 @@ export class LlEmitter { B.startBlock(B.newLabel("exc.dead")); } + /** Emits `line`, a `call` of `callee`, and reports whether the call + * needs no pending check after it: a call of an unwinding-ABI function + * becomes an `invoke` whose landing pad runs this frame's unwind (the + * same releases and handler as emitPendingCheck's unwind path), and the + * normal path continues in a fresh block. Coroutine bodies keep plain + * calls (unwindAbiFunctions excludes their callees). */ + emitDirectCall(line: string, callee: string): boolean { + const B = this.B; + if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) { + B.line(line); + return false; + } + const ok = B.newLabel("inv.ok"); + const pad = B.newLabel("inv.lp"); + this.usesLandingPads = true; + // A trailing comment goes after the invoke's labels. + const comment = line.indexOf(" ; "); + const text = comment < 0 ? line : line.slice(0, comment); + B.terminate( + `${text.replace(/^((?:%[\w.]+ = )?)call /, "$1invoke ")} to label %${ok} unwind label %${pad}`, + ); + B.startBlock(pad); + const exception = B.tmp(); + B.line(`${exception} = landingpad { ptr, i32 } cleanup`); + if (this.currentUnwindAbi) B.line(`store { ptr, i32 } ${exception}, ptr ${UNWIND_SLOT}`); + this.inLandingPad = true; + this.emitUnwind(); + this.inLandingPad = false; + B.startBlock(ok); + return true; + } + + /** Whether emitUnwind runs for a landing pad, whose unwind goes on with + * `resume` instead of raising again. */ + private inLandingPad = false; + + /** Whether the function being emitted has a landing pad (its definition + * needs the personality). */ + usesLandingPads = false; + /** A recursive function's entry check: below the running stack's guard, * throw Node's RangeError and unwind like any other pending exception. */ emitStackCheck(): void { @@ -3569,15 +3742,14 @@ export class LlEmitter { set.add(fn.name); writers.set(localId, set); }; - everyStmtList(fn.body, { - stmt: (s) => { - if (s.kind === "assign" || s.kind === "varDecl") note(s.localId); - return true; - }, - expr: (e) => { - if (e.kind === "assignExpr" || e.kind === "incDec") note(e.localId); - return true; - }, + eachNodeOfKinds(fn, this.nodeIndex.scan(GLOBAL_WRITE_KINDS), (node) => { + if ( + node.kind === "assign" || + node.kind === "varDecl" || + node.kind === "assignExpr" || + node.kind === "incDec" + ) + note(node.localId); }); } this.globalWriters = writers; @@ -4311,11 +4483,21 @@ export class LlEmitter { workers: this.mod.workers === true, owned: this.bytesOwned, scalarStrings: new Set(this.stringSlices.keys()), + unitSafeFunctions: this.unitSafeFunctions(), }, paired, ); } + private unitSafe: ReadonlySet | null | undefined; + /** The functions a loop over a string's UTF-16 units may call, or null + * where emitted reads do not use the runtime's scan receiver. */ + private unitSafeFunctions(): ReadonlySet | null { + if (this.unitSafe === undefined) + this.unitSafe = stringScanInline(this) ? unitSafeFunctions(this.mod) : null; + return this.unitSafe; + } + /** The alias tag of a whole-slot load of a binding (module globals only). */ slotAlias(id: string): string { const binding = this.binding(id); @@ -5158,7 +5340,22 @@ export class LlEmitter { if (fn.captures !== undefined) params.unshift("ptr %sc_env"); const ret = this.llType(fn.returnType); const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`; - B.line(ret === "void" ? call : `%result = ${call}`); + // The owned entry of an unwinding-ABI body releases the borrowed + // parameters on the unwind too, then resumes it to its caller. + const unwinding = this.unwindAbi.has(fn.name) && kept.size === 0; + if (unwinding) { + const ok = B.newLabel("adapt.ok"); + const pad = B.newLabel("adapt.lp"); + B.terminate( + `${ret === "void" ? "" : "%result = "}invoke ${call.slice(5)} to label %${ok} unwind label %${pad}`, + ); + B.startBlock(pad); + const exception = B.tmp(); + B.line(`${exception} = landingpad { ptr, i32 } cleanup`); + for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type); + B.terminate(`resume { ptr, i32 } ${exception}`); + B.startBlock(ok); + } else B.line(ret === "void" ? call : `%result = ${call}`); // A borrowed result is reachable from the parameters: own it first. A // throwing body returns a null dummy, which needs no owner (and string // or array retains do not accept NULL). @@ -5184,7 +5381,9 @@ export class LlEmitter { B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`); const symbol = kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name); - return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; + if (unwinding) this.declare(`declare i32 @__gcc_personality_v0(...)`); + const personality = unwinding ? ` ${UNWIND_PERSONALITY}` : ""; + return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS}${personality} {\n${B.render()}\n}`; } canBorrowCallArgument(value: IrExpr): boolean { return canBorrowCallArgument(this, value); @@ -5357,13 +5556,21 @@ export class LlEmitter { private loopMetricsRead: boolean | undefined; readsLoopMetrics(): boolean { if (this.loopMetricsRead !== undefined) return this.loopMetricsRead; - const visitor = { - expr: (e: IrExpr): boolean => - !(e.kind === "libCall" && (e.fn === "perf.loopStart" || e.fn === "perf.idleTime")), - stmt: (): boolean => true, - }; - this.loopMetricsRead = !this.mod.functions.every((fn) => everyStmtList(fn.body, visitor)); - return this.loopMetricsRead; + let found = false; + const scan = this.nodeIndex.scan(LIB_CALL_KINDS); + for (const fn of this.mod.functions) { + eachNodeOfKinds(fn, scan, (node) => { + if ( + node.kind === "libCall" && + (node.fn === "perf.loopStart" || node.fn === "perf.idleTime") + ) + found = true; + return !found; + }); + if (found) break; + } + this.loopMetricsRead = found; + return found; } private lastUseMovesAllowed(): boolean { @@ -5731,6 +5938,10 @@ export class LlEmitter { }); return; } + if (e.kind === "dynInvoke" && e.calleeNameValue === undefined) { + emitDynamicExpr(this, e, true); + return; + } this.emitExpr(e); } diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 5a16dabf3..a466a7e5f 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -245,38 +245,46 @@ export function emitCallExpr( ? INTEGER_DISPATCH_SUFFIX : ""; const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}${dispatch}`; + // A call of an unwinding-ABI callee is an invoke (emitDirectCall): + // its landing pad unwinds, so no pending check follows it. if (e.type.kind === "void") { - B.line(`call void ${target}(${argList})`); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + const invoked = host.emitDirectCall(`call void ${target}(${argList})`, e.callee); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); if (borrowed) host.releaseTemps(host.frames.pop()!); return { name: "", type: e.type }; } const t = B.tmp(); if (abi?.union) { - B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + const invoked = host.emitDirectCall( + `${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`, + e.callee, + ); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); if (borrowed) host.releaseTemps(host.frames.pop()!); if (stackResult) return host.stackScalarUnion(t, e.type); return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type }); } if (abi?.result) { - B.line(`${t} = call i32 ${target}(${argList})`); + const invoked = host.emitDirectCall(`${t} = call i32 ${target}(${argList})`, e.callee); const out = host.int32Result(t, e.type); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out); if (borrowed) host.releaseTemps(host.frames.pop()!); return out; } - B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`); + const invoked = host.emitDirectCall( + `${t} = call ${host.llType(e.type)} ${target}(${argList})`, + e.callee, + ); let out: LlValue; if (borrowed && host.borrowedReturns.has(e.callee)) { // A borrowed-return body hands back +0 (a null dummy when it threw): // test the exception first, then own the result before the // argument snapshots that keep it reachable are released. - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); out = host.own({ name: host.retainFrame(t, e.type), type: e.type }); } else { out = host.own({ name: t, type: e.type }); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out); } if (borrowed) { host.toFrame(out); @@ -1378,10 +1386,11 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call ) .join(", "); const t = host.B.tmp(); - host.B.line( + const invoked = host.emitDirectCall( `${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}(${argList}) ; borrowed result`, + e.callee, ); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(); return { name: t, type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 279b8c044..31643bdfa 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -53,6 +53,7 @@ import { STRING_CHAR_CODE_AT_INT_MARKER, emitAsciiChar, emitAsciiCharCode, + emitUnitCharCode, STRING_UTF16_LEN_DECL, } from "./string-reads.js"; import { exactInteger, widenInteger } from "./integer-values.js"; @@ -322,6 +323,15 @@ export function charAtUnitRead( return read; } +/** The UTF-16 units an enclosing loop version proved the index of `e` + * below the count of (string-unit-versions.ts). */ +function provenUnitsRead(host: LlvmEmitterContext, e: IrExpr): string | null { + const units = host.stringUnitReads.get(e); + if (units !== undefined) return units; + const origin = host.charAtUnitReads.get(e); + return origin !== undefined ? (host.stringUnitReads.get(origin) ?? null) : null; +} + /** An enclosing loop version proved the string of `e` ASCII and its index * below the length (bytes-loop-versions.ts). */ function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean { @@ -364,9 +374,14 @@ export function emitStrIntrinsic( const method = e.method; switch (method) { case "length": { + const t = B.tmp(); + const length = host.stringLengthReads.get(e); + if (length !== undefined) { + B.line(`${t} = sitofp i64 ${length} to double`); + return { name: t, type: e.type }; + } // sc_str_utf16_len (string-reads.ts) answers proven-ASCII strings inline. host.declare(STRING_UTF16_LEN_DECL); - const t = B.tmp(); B.line(`${t} = call double @sc_str_utf16_len(ptr ${r.name})`); return { name: t, type: e.type }; } @@ -376,6 +391,8 @@ export function emitStrIntrinsic( const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null; if (index) { const wide = widenInteger(host, index); + const units = provenUnitsRead(host, e); + if (units !== null) return { name: emitUnitCharCode(host, units, wide), type: e.type }; if (provenStringRead(host, e)) return { name: emitAsciiCharCode(host, r.name, wide), type: e.type }; host.declare(STRING_CHAR_CODE_AT_INT_MARKER); @@ -1461,6 +1478,7 @@ export function emitMapLikeIntrinsic( : emitStringMapGetScalar(host, r.name, stringKey!); found = scalar.found; if (stringKey !== null) rememberMapEntry(host, e, r.name, stringKey, scalar.entry); + else rememberMapEntry(host, e, r.name, numberKey!, scalar.entry, "double"); readHit = () => { const hit = B.tmp(); B.line( @@ -1532,7 +1550,13 @@ export function emitMapLikeIntrinsic( const vAcc = elemAccess(receiverType.value); if (vAcc === "ref") host.toHeap(v); const ordinary = (): void => host.mapSet(r.name, kAcc, vAcc, k.name, v.name); - if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) { + if ( + (kAcc === "str" || kAcc === "f64") && + vAcc !== "ref" && + emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary) + ) { + // Written through the read's entry, or inserted. + } else if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) { const bits = B.tmp(); B.line( vAcc === "f64" @@ -1540,12 +1564,7 @@ export function emitMapLikeIntrinsic( : `${bits} = zext i1 ${v.name} to i64`, ); emitNumberMapSetScalar(host, r.name, k.name, bits, ordinary); - } else if ( - kAcc !== "str" || - vAcc === "ref" || - !emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary) - ) - ordinary(); + } else ordinary(); return { name: "", type: e.type }; } case "add": { diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index c6dc506f4..ac5262571 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -30,6 +30,9 @@ export interface WideInteger { name: string; below: number; bound: number; + /** The double is the integer rounded once: the result of one operation on + * operands that always equal their doubles. */ + single?: true; } export type ExprOf = Extract; diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 26905a7ef..3d9aec3cc 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -23,6 +23,7 @@ import { isUnitType, typeEquals, typeKey, + VOID, } from "../../ir/ir.js"; import { BYTES_ELEM_NUM, closureIdentityEqual } from "./common.js"; import { DYN_KIND } from "./dyn.js"; @@ -57,6 +58,7 @@ export function emitDynamicExpr( | "caughtNarrow" | "caughtToDyn" >, + discarded = false, ): LlValue { const B = host.B; switch (e.kind) { @@ -87,7 +89,7 @@ export function emitDynamicExpr( // Bytes already box their shared mutable storage directly. A second // capsule would split identity between checked and native views. const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className); - // A TextEncoder or TextDecoder record boxes as its own handle, which + // A TextEncoder, TextDecoder or StringDecoder record boxes as its own handle, which // keeps the record's identity (toDynHelper). const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null; if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) { @@ -257,6 +259,18 @@ export function emitDynamicExpr( // Each call passes its method name's id slot, so only the first call // looks the name up. const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id"; + // A statement-position push drops the new length, so its entry + // need not box it. + if (discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id") { + host.declare( + `declare void @scr_dyn_invoke_discard_id(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`, + ); + B.line( + `call void @scr_dyn_invoke_discard_id(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${host.cstr(e.calleeName)})`, + ); + host.emitPendingCheck(); + return { name: "", type: VOID }; + } host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`); let name = host.cstr(e.calleeName); if (e.calleeNameValue !== undefined) { diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index ccbe3edcc..1ff7d47a4 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -13,13 +13,14 @@ import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { - alwaysExactInteger, + exactCompareOperands, exactInteger, int32Number, integerNumber, narrowInteger, wideArithmetic, widenInteger, + type LlInteger, } from "./integer-values.js"; import { bitwiseIdentityOperand, @@ -45,7 +46,7 @@ import { slotWidth, type ArrayAccess, } from "./dense-array-access.js"; -import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; +import { emitWrappingSum, integerOrNaNRange, markWrappingOperands } from "./wrapping-sums.js"; import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js"; /** The signed integer predicates of the number compares. */ @@ -58,6 +59,63 @@ const INTEGER_COMPARES: Record = { "!==": "ne", }; +const UNSIGNED_COMPARES: Record = { + "<": "ult", + "<=": "ule", + ">": "ugt", + ">=": "uge", + "===": "eq", + "!==": "ne", +}; + +/** A number compare of operands that are each an exact integer or an + * integer-or-NaN value (integerOrNaNRange: integer typed-array elements, + * `charCodeAt` results, sums of those), all within int32 or all within + * uint32: their 32-bit views compared, and NaN comparing false (unequal for + * `!==`). The 32-bit view is the value's ToUint32, which holds its bits + * whenever it is not NaN. Null when an operand has no such view. */ +function emitIntegerOrNaNCompare( + host: LlvmEmitterContext, + e: ExprOf<"bin">, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): string | null { + const lr = li?.range ?? integerOrNaNRange(host, e.left), + rr = ri?.range ?? integerOrNaNRange(host, e.right); + if (!lr || !rr) return null; + const signed = withinInt32(lr) && withinInt32(rr); + if (!signed && !(withinUint32(lr) && withinUint32(rr))) return null; + const bits = (value: LlValue, integer: LlInteger | null): string | undefined => + integer ? narrowInteger(host, integer) : value.uint32; + const left = bits(l, li), + right = bits(r, ri); + if (left === undefined || right === undefined) return null; + const B = host.B; + const compare = B.tmp(); + B.line( + `${compare} = icmp ${(signed ? INTEGER_COMPARES : UNSIGNED_COMPARES)[e.op]} i32 ${left}, ${right}`, + ); + let result = compare; + for (const [value, integer] of [ + [l, li], + [r, ri], + ] as const) { + if (integer) continue; + const ordered = B.tmp(), + combined = B.tmp(); + B.line(`${ordered} = fcmp ord double ${value.name}, 0.0`); + if (e.op === "!==") { + const unordered = B.tmp(); + B.line(`${unordered} = xor i1 ${ordered}, true`); + B.line(`${combined} = or i1 ${result}, ${unordered}`); + } else B.line(`${combined} = and i1 ${result}, ${ordered}`); + result = combined; + } + return result; +} + export function emitLiteralExpr( host: LlvmEmitterContext, e: ExprOf<"numLit" | "boolLit" | "strLit" | "moduleNsRef" | "unitLit" | "varRef">, @@ -363,14 +421,16 @@ export function emitOperatorExpr( return value; } } else { - // An operand with only a wide view, such as `i + 1` of a speculated - // counter: compare the integers when both always equal the doubles. - const left = alwaysExactInteger(host, l, li), - right = left === null ? null : alwaysExactInteger(host, r, ri); - if (left !== null && right !== null) { - B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`); + // An operand with only a wide view, such as `i + 1` or `2 * i + 1` + // of a speculated counter: compare the integers when that gives the + // doubles' result. + const operands = exactCompareOperands(host, l, li, r, ri); + if (operands) { + B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${operands[0]}, ${operands[1]}`); return { name: t, type: e.type }; } + const nanCoded = emitIntegerOrNaNCompare(host, e, l, li, r, ri); + if (nanCoded) return { name: nanCoded, type: e.type }; B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); } } else if (bit[e.op] !== undefined) { diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 675932d47..28f5ae5bc 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -1,7 +1,7 @@ import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; +import { streamRooted, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; import { type IrRecordShape, type IrType, @@ -843,8 +843,7 @@ export function streamTypedRefMaterializeAdapter( host.declare(`declare ptr @scr_error_dyn_fields(ptr)`); B.line(`${out} = call ptr @scr_error_dyn_fields(ptr %p)`); } else if ( - meta.def.runtime && - RUNTIME_STREAM_CLASSES.has(t.className) && + ((meta.def.runtime && RUNTIME_STREAM_CLASSES.has(t.className)) || streamRooted(meta)) && !meta.def.instancePrototypeHelper && !meta.def.prototypeDataHelper ) { diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 90954f4c6..5b8935036 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -111,13 +111,17 @@ export function wideArithmetic( rv = view(r, ri); if (!lv || !rv) return null; let below: number, bound: number; + let single = false; if (op === "*") { if (lv.below !== Infinity || rv.below !== Infinity) return null; - below = EXACT_DOUBLES; bound = productBound(lv.bound, rv.bound); + // Scaling by a power of two never rounds a double that stays finite. + below = powerOfTwo(li) || powerOfTwo(ri) ? Infinity : EXACT_DOUBLES; + single = true; } else if (lv.below === Infinity && rv.below === Infinity) { below = EXACT_DOUBLES; bound = sumBound(lv.bound, rv.bound); + single = true; } else if (lv.below === Infinity || rv.below === Infinity) { const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv]; below = Math.min(wide.below - exact.bound, EXACT_DOUBLES); @@ -132,7 +136,50 @@ export function wideArithmetic( host.B.line( `${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`, ); - return { name, below, bound }; + return single ? { name, below, bound, single } : { name, below, bound }; +} + +/** Whether an exact integer is the constant 2^k or -2^k. */ +function powerOfTwo(integer: LlInteger | null): boolean { + if (integer === null || integer.range.min !== integer.range.max) return false; + const value = Math.abs(integer.range.min); + // Math.log2 can round to an integer for a value that is not a power of + // two (2^52 - 1), so the candidate power is compared with the value. + return value >= 1 && value <= 2 ** 52 && 2 ** Math.round(Math.log2(value)) === value; +} + +/** The i64 operands of a number compare that gives the doubles' result: + * both equal their doubles wherever they can be, or one is an exact integer + * of magnitude at most 2^53 - 1 and the other's double is its integer + * rounded once. Rounding is monotonic, and b - 1, b and b + 1 are doubles + * for a safe integer b, so an integer rounded once compares with b as the + * integer does. Null for any other pair. */ +export function exactCompareOperands( + host: LlvmEmitterContext, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): [string, string] | null { + const describe = (value: LlValue, integer: LlInteger | null) => { + if (integer) return { exact: true, single: true, bound: magnitude(integer.range) }; + const wide = value.wideInteger; + if (!wide) return null; + return { exact: wide.bound <= wide.below, single: wide.single === true, bound: wide.bound }; + }; + const a = describe(l, li), + b = describe(r, ri); + if (!a || !b) return null; + const safe = (d: { exact: boolean; bound: number }): boolean => + d.exact && d.bound <= Number.MAX_SAFE_INTEGER; + const ok = + (a.exact && b.exact) || + (safe(a) && (b.exact || b.single)) || + (safe(b) && (a.exact || a.single)); + if (!ok) return null; + const operand = (value: LlValue, integer: LlInteger | null): string => + integer ? widenInteger(host, integer) : value.wideInteger!.name; + return [operand(l, li), operand(r, ri)]; } /** An i64 index for a consumer that compares it, unsigned, with a length or diff --git a/packages/compiler/src/backend/llvm/integer-views.ts b/packages/compiler/src/backend/llvm/integer-views.ts index c38770160..579f010a2 100644 --- a/packages/compiler/src/backend/llvm/integer-views.ts +++ b/packages/compiler/src/backend/llvm/integer-views.ts @@ -1,6 +1,9 @@ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; + +const LOOP_KINDS: readonly IrNodeKind[] = ["for", "forOf", "while", "doWhile"]; /** Keep a computed ToUint32 view beside an ordinary number when integer * consumers in loops can reuse it. The number remains authoritative for floating @@ -14,9 +17,16 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js"; * compare converts the stored number back, and LLVM moves that number's * conversion into the join of a NaN-coded typed-array read (`phi [sitofp x, * 0.0]`), where nothing folds it back to the integer. */ -export function findIntegerViews(fn: IrFunction): Set { +export function findIntegerViews(fn: IrFunction, nodes?: IrNodeIndex): Set { const wanted = new Set(); if (fn.async || fn.generator) return wanted; + // Every view is wanted inside a loop: a body without one wants none. + let loops = false; + eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => { + if (statement && LOOP_KINDS.includes(node.kind)) loops = true; + return !loops; + }); + if (!loops) return wanted; const initialized = new Set(fn.params.map((p) => p.localId)); const excluded = new Set( [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), diff --git a/packages/compiler/src/backend/llvm/lazy-captures.ts b/packages/compiler/src/backend/llvm/lazy-captures.ts index 2014869af..85acff2ef 100644 --- a/packages/compiler/src/backend/llvm/lazy-captures.ts +++ b/packages/compiler/src/backend/llvm/lazy-captures.ts @@ -1,5 +1,8 @@ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { everyStmtList } from "../../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; + +const CLOSURE_KINDS: readonly IrNodeKind[] = ["closure"]; /** The local a node rebinds. Writes through the bound reference, such as a * field store or an array push, leave the binding unchanged. Catch bindings @@ -37,7 +40,11 @@ export class LazyCaptures { private readonly pending = new Set(); private sites: Map | null = null; - constructor(private readonly functions: ReadonlyMap) {} + constructor( + private readonly functions: ReadonlyMap, + /** The module's node index, read instead of walking the bodies it holds. */ + private readonly nodes?: IrNodeIndex, + ) {} /** The captures of closure body `fnName` that hold one value for the * closure's whole life: at every creation site the captured binding is @@ -103,16 +110,15 @@ export class LazyCaptures { private creationSites(): Map { if (this.sites !== null) return this.sites; const sites = new Map(); + const scan = this.nodes?.scan(CLOSURE_KINDS); for (const fn of this.functions.values()) { - const visit = (node: IrStmt | IrExpr): boolean => { + eachNodeOfKinds(fn, scan, (node) => { if (node.kind === "closure") { let list = sites.get(node.fnName); if (!list) sites.set(node.fnName, (list = [])); list.push({ owner: fn.name, captures: node.captures }); } - return true; - }; - everyStmtList(fn.body, { stmt: visit, expr: visit }); + }); } this.sites = sites; return sites; diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 0797328fa..ed0e4a5ed 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -726,6 +726,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll if (MAY_THROW_LIB_FNS.has(e.fn) && LIB_FN_SYMS[e.fn] === undefined) { throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc); } + // A setter's write: the time value into the Date object (ScrDate.ms). + if (e.fn === "date.store") { + const date = emitBorrowedInput(host, e.args[0]!).name; + const ms = host.emitExpr(e.args[1]!).name; + const field = B.tmp(); + B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`); + B.line(`store double ${ms}, ptr ${field}`); + return { name: ms, type: e.type }; + } // queueMicrotask's checked-dynamic form carries the AsyncLocalStorage // context like the static one (lib-process.ts). const sym = diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 1a3973b07..74ab34971 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -119,6 +119,13 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr B.line(`call void @scr_child_on_error(ptr ${child.name}, ptr ${cb.name}, ptr @${adapter})`); return { name: "", type: e.type }; } + if (e.fn === "child.hasChannel") { + const child = host.emitExpr(e.args[0]!); + host.declare(`declare zeroext i1 @scr_child_ipc_present(ptr)`); + const out = B.tmp(); + B.line(`${out} = call i1 @scr_child_ipc_present(ptr ${child.name})`); + return { name: out, type: e.type }; + } if (e.fn === "child.connected") { const child = host.emitExpr(e.args[0]!); host.declare(`declare zeroext i1 @scr_child_ipc_connected(ptr)`); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index e1eafab89..2366953c5 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -37,6 +37,7 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap = new Ma export const LIB_FN_SYMS: Record = { "assert.expectsErrDyn": "scr_assert_expects_err_dyn", + "assert.throwsValidate": "scr_assert_throws_validate", "insp.jsonDyn": "scr_util_format_j", "http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw", "intl.segmenterNew": "scr_intl_segmenter_new", @@ -82,6 +83,7 @@ export const LIB_FN_SYMS: Record = { "util.isDeepStrictEqual": "scr_util_is_deep_strict_equal", "util.styleText": "scr_util_style_text", "util.inherits": "scr_util_inherits", + "util.inheritsPrototype": "scr_util_inherits_prototype", "util.deprecate": "scr_util_deprecate", "util.callbackify": "scr_util_callbackify", "util.promisifyDyn": "scr_util_promisify", @@ -334,6 +336,8 @@ export const LIB_FN_SYMS: Record = { "date.getTime": "scr_date_get_time", "date.valueOf": "scr_date_get_time", "date.fromTime": "scr_date_get_time", + "date.setFields": "scr_date_set_fields", + "date.timeClip": "scr_date_new_ms", "date.parseGetTime": "scr_date_parse", "date.utc": "scr_date_utc", "date.newLocal": "scr_date_new_local", @@ -399,6 +403,9 @@ export const LIB_FN_SYMS: Record = { "assert.eqBool": "scr_assert_eq_bool", "assert.eqSym": "scr_assert_eq_sym", "assert.eqDyn": "scr_assert_eq_dyn", + "assert.messageValue": "scr_assert_message_value", + "assert.methodValue": "scr_assert_method_value", + "assert.messageClear": "scr_assert_message_clear", "assert.looseResult": "scr_assert_loose_result", "assert.deepResult": "scr_assert_deep_result", "assert.looseDeepResult": "scr_assert_loose_deep_result", diff --git a/packages/compiler/src/backend/llvm/loop-array-borrows.ts b/packages/compiler/src/backend/llvm/loop-array-borrows.ts index 9335894d9..cc9e03a5c 100644 --- a/packages/compiler/src/backend/llvm/loop-array-borrows.ts +++ b/packages/compiler/src/backend/llvm/loop-array-borrows.ts @@ -1,5 +1,6 @@ import { F64, isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { ReferenceEffects } from "./reference-effects.js"; import type { OptionalArrayReads } from "./local-array-reads.js"; @@ -57,6 +58,8 @@ export function dynHoleRead(value: IrExpr): (IrExpr & { kind: "arrayGet" }) | nu return same(cond.left.arr, then.arr) && same(cond.left.index, then.index) ? then : null; } +const LOOP_KINDS: readonly IrNodeKind[] = ["forOf", "for", "while", "doWhile"]; + /** Limit a borrow to a loop whose complete iteration preserves heap edges. * Initialization and unrelated work elsewhere in the function may mutate. * The array remains owned by its unchanged local or global binding; the @@ -72,6 +75,8 @@ export function findLoopArrayBorrows( optionalReads?: OptionalArrayReads, writes?: ReferenceWrites | null, functions?: ReadonlyMap, + /** The module's node index, read instead of walking for the loops. */ + nodes?: IrNodeIndex, ): ReadonlySet { const result = new Set(); if (fn.async || fn.generator) return result; @@ -88,9 +93,10 @@ export function findLoopArrayBorrows( (!local.boxed && !local.tdz && (bindings?.has(local.id) || parameters.has(local.id))) ); }; - everyStmtList(fn.body, { - expr: () => true, - stmt: (loop) => { + eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => { + if (!statement) return true; + const loop = node as IrStmt; + { if (loop.kind === "forOf") { const local = locals.get(loop.localId); if ( @@ -187,7 +193,7 @@ export function findLoopArrayBorrows( }, }); return true; - }, + } }); return result; } diff --git a/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts index 58d3a33d6..9c8ffb84c 100644 --- a/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts +++ b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts @@ -137,12 +137,20 @@ test("a read followed by a write of the same map pairs, and the write reuses the } }); -test("number keys keep the write-after-read lookup instead of pairing", () => { - // findReadNumberMapWrites stores such writes into the found entry through - // a read-only probe that LLVM merges with the read's. - const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(F64))], F64, F64); - expect(pairs(mod)).toBe(0); - expect(body(mod)).not.toContain("mapfuse.hit"); +test("number keys pair, and the write compares the key's bits with the read's", () => { + for (const value of [F64, BOOL]) { + const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(value))], F64, value); + expect(pairs(mod)).toBe(1); + for (const bits of [32, 64] as const) { + const ir = body(mod, bits); + expect(ir).toContain("mapfuse.hit"); + expect(ir).toContain("alloca double ; fused map read: key"); + expect(ir).toMatch(/icmp eq i64 %t\d+, %t\d+/); + // The miss still inserts through the runtime. + expect(ir).toMatch(/call void @scr_map_set_f64_(?:f64|bool)\(/); + expect(ir).toContain("@sc_map_entry_f64("); + } + } }); test("the single-statement form pairs an unconditional nested read", () => { diff --git a/packages/compiler/src/backend/llvm/map-entry-fusion.ts b/packages/compiler/src/backend/llvm/map-entry-fusion.ts index bc2ca07f3..fe6f66d9c 100644 --- a/packages/compiler/src/backend/llvm/map-entry-fusion.ts +++ b/packages/compiler/src/backend/llvm/map-entry-fusion.ts @@ -14,10 +14,10 @@ * key match by identity implies the SameValueZero match the set would find. * * Only scalar values (number, boolean) fuse: overwriting them releases - * nothing, exactly like the runtime's own overwrite. Only string-keyed - * typed maps fuse here: a number-key write after a read of the same key - * already probes through the read-only entry lookup that LLVM merges with - * the read's (findReadNumberMapWrites, map-read-lifetimes.ts). + * nothing, exactly like the runtime's own overwrite. String-keyed and + * number-keyed typed maps fuse. A number key matches when its bits are the + * read's: such keys are SameValueZero-equal, and a key that is equal with + * other bits (-0 for 0) takes the ordinary set. */ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; @@ -30,7 +30,7 @@ type MapOp = Extract; /** One fused read/write pair. The slots exist once the read emits through * an entry path; a write whose read took another path stays ordinary. */ export interface MapEntryPair { - slots: { map: string; key: string; entry: string } | null; + slots: { map: string; key: string; entry: string; keyType: "ptr" | "double" } | null; } export interface MapEntryFusion { @@ -38,13 +38,14 @@ export interface MapEntryFusion { writes: Map; } -/** A typed string-keyed Map read/write with a scalar value. */ +/** A typed string- or number-keyed Map read/write with a scalar value. */ function fusableOp(e: IrExpr, method: "get" | "set"): e is MapOp { if (e.kind !== "mapIntrinsic" || e.method !== method) return false; const receiver = e.receiver.type; - if (receiver.kind !== "map") return false; + if (receiver.kind !== "map" || receiver.key.kind === "union") return false; if (receiver.value.kind !== "f64" && receiver.value.kind !== "bool") return false; - if (mapKeyAccess(receiver.key) !== "str") return false; + const access = mapKeyAccess(receiver.key); + if (access !== "str" && access !== "f64") return false; return method === "get" ? borrowsMapReadInputs(e) : borrowsMapMutationReceiver(e); } @@ -219,23 +220,25 @@ export function findMapEntryFusions(fn: IrFunction): MapEntryFusion { return out; } -/** After a fused read resolved `entry` for the string `key` in `map`: park - * all three for the paired write. */ +/** After a fused read resolved `entry` for `key` in `map`: park all three + * for the paired write. `keyType` is the key's LLVM type: a string's ptr or + * a number's double. */ export function rememberMapEntry( host: LlvmEmitterContext, read: IrExpr, map: string, key: string, entry: string, + keyType: "ptr" | "double" = "ptr", ): void { const pair = host.mapEntryFusion?.reads.get(read); if (!pair) return; const B = host.B; if (pair.slots === null) { - const slots = { map: B.slot(), key: B.slot(), entry: B.slot() }; + const slots = { map: B.slot(), key: B.slot(), entry: B.slot(), keyType }; B.entryAllocas.push( `${slots.map} = alloca ptr ; fused map read: map`, - `${slots.key} = alloca ptr ; fused map read: key`, + `${slots.key} = alloca ${keyType} ; fused map read: key`, `${slots.entry} = alloca ptr ; fused map read: entry`, `store ptr null, ptr ${slots.entry}`, ); @@ -243,7 +246,7 @@ export function rememberMapEntry( } const slots = pair.slots; B.line(`store ptr ${map}, ptr ${slots.map}`); - B.line(`store ptr ${key}, ptr ${slots.key}`); + B.line(`store ${slots.keyType} ${key}, ptr ${slots.key}`); B.line(`store ptr ${entry}, ptr ${slots.entry}`); } @@ -270,13 +273,20 @@ export function emitFusedMapSet( both = B.tmp(), reuse = B.tmp(); B.line(`${parkedMap} = load ptr, ptr ${slots.map}`); - B.line(`${parkedKey} = load ptr, ptr ${slots.key}`); + B.line(`${parkedKey} = load ${slots.keyType}, ptr ${slots.key}`); B.line(`${entry} = load ptr, ptr ${slots.entry}`); // The entry is valid only between its read and this write. B.line(`store ptr null, ptr ${slots.entry}`); B.line(`${found} = icmp ne ptr ${entry}, null`); B.line(`${sameMap} = icmp eq ptr ${parkedMap}, ${map}`); - B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`); + if (slots.keyType === "ptr") B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`); + else { + const parkedBits = B.tmp(), + keyBits = B.tmp(); + B.line(`${parkedBits} = bitcast double ${parkedKey} to i64`); + B.line(`${keyBits} = bitcast double ${key} to i64`); + B.line(`${sameKey} = icmp eq i64 ${parkedBits}, ${keyBits}`); + } B.line(`${both} = and i1 ${sameMap}, ${sameKey}`); B.line(`${reuse} = select i1 ${found}, i1 ${both}, i1 false`); const hit = B.newLabel("mapfuse.hit"), diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts index 6b58d314a..6053d70eb 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts @@ -314,6 +314,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): // Booleans are stored as 0/1, already the widened union payload. const scalar = emitNumberMapGetScalar(host, receiver.name, number); found = scalar.found; + rememberMapEntry(host, read.expr, receiver.name, number, scalar.entry, "double"); B.line(`store i64 ${scalar.bits}, ptr ${payload}`); } } else if (string !== null && !isRefCounted(read.value)) { diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 7af16ab83..61c1a074d 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -110,10 +110,9 @@ describe("LLVM runtime RC symbols", () => { expect(releaseSym(host, { kind: "union", unionId: "u0" } as never)).toBe( "@sc_rc_release_union", ); - // Array retains inline; array releases stay calls. const array = { kind: "array", elem: { kind: "string" } } as never; expect(retainSym(host, array)).toBe("@sc_rc_retain_arr"); - expect(releaseSym(host, array)).toBe("@scr_arr_release"); + expect(releaseSym(host, array)).toBe("@sc_rc_release_arr"); expect(retainSym(host, { kind: "dyn" })).toBe("@sc_rc_retain_dyn"); expect(releaseSym(host, { kind: "dyn" })).toBe("@sc_rc_release_dyn"); // Unmirrored families and caught snapshots keep their runtime entry points. @@ -125,6 +124,7 @@ describe("LLVM runtime RC symbols", () => { expect(declarations).toContain("declare void @scr_str_release(ptr)"); expect(declarations).toContain("declare void @scr_cyc_on_release(ptr)"); expect(inlineRcDecls(host).sort()).toEqual([ + "declare void @scr_arr_release(ptr)", "declare void @scr_cyc_on_release(ptr)", "declare void @scr_dyn_release(ptr)", "declare void @scr_dyn_release_survivor(ptr)", @@ -280,6 +280,22 @@ describe("inline release budgets", () => { ); }); + test("each family's budget applies to its own sites in a function that has both", () => { + const strings = body("mixed", INLINE_STR_RELEASES_PER_FUNCTION + 1, "str"); + // One function: string sites over their budget, dyn sites within theirs. + const mixed = strings.replace( + " ret void", + ` call void @sc_rc_release_dyn(ptr %v)\n call void @sc_rc_release_other(ptr %v)\n ret void`, + ); + const other = body("other", 2, "str"); + const limited = limitInlineReleases([mixed, other], "release").join("\n"); + const out = limited.slice(0, limited.indexOf("@other(")); + expect(out).not.toContain("@sc_rc_release_str("); + expect(out).toContain("call void @sc_rc_release_dyn(ptr %v)"); + expect(out).toContain("call void @sc_rc_release_other(ptr %v)"); + expect(limited).toContain(other); + }); + test("a function over the string budget calls the runtime string release at every site", () => { const small = body("small", INLINE_STR_RELEASES_PER_FUNCTION, "str"); const large = body("large", INLINE_STR_RELEASES_PER_FUNCTION + 1, "str"); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 08d0db9b4..ed6e14b3f 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -550,17 +550,16 @@ interface InlineRcFamily { * scr_dyn_release_survivor for kinds whose candidacy is per value, * including arrays and objects of intact documents (ScrDynRegion). * - * Releases stay plain calls for: - * - arr: array releases are the most numerous release sites and the - * least hot (at most 1% self time across the benchmark suite), and - * inlining them roughly doubled the code growth for no measured speedup. - * Array retains are inlined. */ + * The array release inlines the decrement of an array that stays alive + * and is not headered (`a->cyc`): those are most array releases in + * type-checker workloads, where scr_arr_release's own prologue and checks + * took about half of its time. */ export const INLINE_RC_FAMILIES: Readonly> = { str: { cycle: "none", retainNull: false, release: "scr_str_release" }, bytes: { cycle: "none", retainNull: false, release: "scr_bytes_release" }, regex: { cycle: "none", retainNull: false, release: "scr_regex_release" }, bigint: { cycle: "none", retainNull: true, release: "scr_bigint_release" }, - arr: { cycle: "arr", retainNull: false, release: null }, + arr: { cycle: "arr", retainNull: false, release: "scr_arr_release" }, map: { cycle: "map", retainNull: false, release: "scr_map_release" }, union: { cycle: "always", retainNull: false, release: "scr_union_release" }, closure: { cycle: "always", retainNull: false, release: "scr_closure_release" }, @@ -692,34 +691,32 @@ export function limitInlineReleases( parts: readonly string[], mode: InlineRcMode | undefined, ): string[] { - const dyn = limitInlineReleaseFamily(parts, "dyn", INLINE_DYN_RELEASES_PER_FUNCTION); - return mode === "release" - ? limitInlineReleaseFamily(dyn, "str", INLINE_STR_RELEASES_PER_FUNCTION) - : dyn; -} - -function limitInlineReleaseFamily( - parts: readonly string[], - family: "dyn" | "str", - max: number, -): string[] { - const call = `call void @${inlineRcName(family, "release")}(`; - const runtime = `call void @${INLINE_RC_FAMILIES[family]!.release}(`; + const families: readonly ("dyn" | "str")[] = mode === "release" ? ["dyn", "str"] : ["dyn"]; + const limits = families.map((family) => + family === "dyn" ? INLINE_DYN_RELEASES_PER_FUNCTION : INLINE_STR_RELEASES_PER_FUNCTION, + ); + const calls = families.map((family) => `${inlineRcName(family, "release")}(`); + const runtimes = families.map((family) => `call void @${INLINE_RC_FAMILIES[family]!.release}(`); + const head = "call void @"; + const prefix = `${head}${inlineRcName("", "release")}`; // The function each release site belongs to: the last `define` line - // before it, counted over the joined text. + // before it, counted over the joined text. One walk over each part finds + // the sites of every family. const sites = ( part: string, - each: (offset: number, fn: number) => void, + each: (offset: number, family: number, fn: number) => void, first: number, ): number => { let fn = first; let define = part.startsWith("define ") ? 0 : part.indexOf("\ndefine "); - for (let at = part.indexOf(call); at >= 0; at = part.indexOf(call, at + call.length)) { + for (let at = part.indexOf(prefix); at >= 0; at = part.indexOf(prefix, at + prefix.length)) { + const family = calls.findIndex((call) => part.startsWith(call, at + head.length)); + if (family < 0) continue; while (define >= 0 && define < at) { fn++; define = part.indexOf("\ndefine ", define + 1); } - each(at, fn); + each(at, family, fn); } while (define >= 0) { fn++; @@ -727,29 +724,47 @@ function limitInlineReleaseFamily( } return fn; }; - const counts = new Map(); + const counts = families.map(() => new Map()); + // The functions each part's sites can belong to. + const spans: number[] = []; let fn = 0; - for (const part of parts) fn = sites(part, (_, f) => counts.set(f, (counts.get(f) ?? 0) + 1), fn); - const over = new Set([...counts].filter(([, n]) => n > max).map(([f]) => f)); - if (over.size === 0) return [...parts]; - fn = 0; - return parts.map((part) => { - const cuts: number[] = []; + for (const part of parts) { + spans.push(fn); fn = sites( part, - (at, f) => { - if (over.has(f)) cuts.push(at); - }, + (_, family, f) => counts[family]!.set(f, (counts[family]!.get(f) ?? 0) + 1), fn, ); + spans.push(fn); + } + const over = counts.map( + (count, family) => new Set([...count].filter(([, n]) => n > limits[family]!).map(([f]) => f)), + ); + if (over.every((set) => set.size === 0)) return [...parts]; + const anyOver = new Set(over.flatMap((set) => [...set])); + const touches = (from: number, to: number): boolean => { + for (let f = from; f <= to; f++) if (anyOver.has(f)) return true; + return false; + }; + return parts.map((part, index) => { + const from = spans[2 * index]!; + if (!touches(from, spans[2 * index + 1]!)) return part; + const cuts: { at: number; family: number }[] = []; + sites( + part, + (at, family, f) => { + if (over[family]!.has(f)) cuts.push({ at, family }); + }, + from, + ); if (cuts.length === 0) return part; let out = ""; - let from = 0; - for (const at of cuts) { - out += part.slice(from, at) + runtime; - from = at + call.length; + let next = 0; + for (const cut of cuts) { + out += part.slice(next, cut.at) + runtimes[cut.family]!; + next = cut.at + head.length + calls[cut.family]!.length; } - return out + part.slice(from); + return out + part.slice(next); }); } diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 441c925f3..08df79cf2 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -75,6 +75,19 @@ export function emitAsciiCharCode(host: LlvmEmitterContext, s: string, index: st return code; } +/** Unit `index` (an i64, below their count) of a string's UTF-16 `units` + * (ScrStrScan): one 16-bit load, as a double. */ +export function emitUnitCharCode(host: LlvmEmitterContext, units: string, index: string): string { + const B = host.B; + const pointer = B.tmp(), + unit = B.tmp(), + code = B.tmp(); + B.line(`${pointer} = getelementptr inbounds i16, ptr ${units}, i64 ${index}`); + B.line(`${unit} = load i16, ptr ${pointer}`); + B.line(`${code} = uitofp i16 ${unit} to double`); + return code; +} + /** The one-character string of unit `index` (an i64) of a proven-ASCII * string `s` whose length covers it: the immortal `@sc_ascii_chars` entry * for its byte, as the inline charAt helpers answer. */ @@ -114,6 +127,15 @@ function loadLength(sz: string): string[] { ]; } +/** Whether emitted reads use the runtime's scan receiver: from ordinary + * global storage, or, in worker executables, from the thread's static TLS + * block (executableTls). */ +export function stringScanInline(host: LlvmEmitterContext): boolean { + return ( + host.executableTls || (host.mod.workers !== true && host.mod.lib?.threadInstances !== true) + ); +} + /** The runtime's ScrStrScan record and the declaration naming it. */ export function stringScanType(sz: string): string { return `{ ptr, ptr, ${sz} }`; diff --git a/packages/compiler/src/backend/llvm/string-unit-versions.test.ts b/packages/compiler/src/backend/llvm/string-unit-versions.test.ts new file mode 100644 index 000000000..e21803948 --- /dev/null +++ b/packages/compiler/src/backend/llvm/string-unit-versions.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import type { IrModule, IrStmt } from "../../ir/ir.js"; +import { unitSafeFunctions, unitSafeLoop } from "./string-unit-versions.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-versions-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const loopOf = (mod: IrModule, name: string): IrStmt & { kind: "for" } => + mod.functions.find((f) => f.name === name)!.body.find((s) => s.kind === "for") as IrStmt & { + kind: "for"; + }; + +test("loops over a string's units call only functions that read no string", async () => { + const mod = await lower(` +function lower(c: number): boolean { + return c >= 97 && c <= 122; +} +function word(c: number): boolean { + return lower(c) || (c >= 48 && c <= 57) || Math.abs(c - 200) < 50; +} +function codeOf(t: string, i: number): number { + return t.charCodeAt(i); +} +function viaCodeOf(t: string): number { + return codeOf(t, 0); +} +function words(s: string): number { + let n = 0; + const starts = [0]; + for (let i = 0; i < s.length; i++) { + if (word(s.charCodeAt(i))) n++; + else starts.push(i); + } + return n + starts.length; +} +function pairs(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + codeOf(t, i); + return n; +} +function both(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + t.charCodeAt(i); + return n; +} +function concatenates(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) out += "-"; + return out; +} +console.log(words("ab c"), pairs("ab", "cd"), both("ab", "cd"), concatenates("a b"), viaCodeOf("x")); +`); + const safe = unitSafeFunctions(mod); + expect(safe.has("lower")).toBe(true); + expect(safe.has("word")).toBe(true); + // A string read, directly or through a call, makes a function unsafe. + expect(safe.has("codeOf")).toBe(false); + expect(safe.has("viaCodeOf")).toBe(false); + + const param = (fn: string, index = 0): string => + mod.functions.find((f) => f.name === fn)!.params[index]!.localId; + // A unit-safe loop answers its receiver's length reads: here the + // condition's `s.length`. + const lengths = unitSafeLoop(loopOf(mod, "words"), param("words"), safe); + expect(lengths?.size).toBe(1); + expect([...lengths!].every((e) => e.kind === "strIntrinsic" && e.method === "length")).toBe(true); + expect(unitSafeLoop(loopOf(mod, "pairs"), param("pairs"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "both"), param("both"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "concatenates"), param("concatenates"), safe)).toBeNull(); +}); + +test("loops that sort with a comparator or copy an untyped source get no units copy", async () => { + // Either can run program code that indexes other strings, which evicts + // the receiver's units while the loop reads them. + const mod = await lower(` +function sorts(s: string, values: Float64Array, compare: (a: number, b: number) => number): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.sort(compare); + } + return n; +} +function copies(s: string, values: Float64Array, source: any): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.set(source); + } + return n; +} +function sortsNumbers(s: string, values: Float64Array): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n += s.charCodeAt(i); + if (i === 3) values.sort(); + } + return n; +} +const values = new Float64Array([2, 1]); +console.log(sorts("abcdef", values, (a, b) => a - b), copies("abcdef", values, [4, 3]), sortsNumbers("abcdef", values)); +`); + const safe = unitSafeFunctions(mod); + const param = (fn: string): string => + mod.functions.find((f) => f.name === fn)!.params[0]!.localId; + expect(unitSafeLoop(loopOf(mod, "sorts"), param("sorts"), safe)).toBeNull(); + expect(unitSafeLoop(loopOf(mod, "copies"), param("copies"), safe)).toBeNull(); + // A numeric sort calls no program code. + expect(unitSafeLoop(loopOf(mod, "sortsNumbers"), param("sortsNumbers"), safe)).not.toBeNull(); +}); diff --git a/packages/compiler/src/backend/llvm/string-unit-versions.ts b/packages/compiler/src/backend/llvm/string-unit-versions.ts new file mode 100644 index 000000000..368b8eae8 --- /dev/null +++ b/packages/compiler/src/backend/llvm/string-unit-versions.ts @@ -0,0 +1,208 @@ +import type { IrBytesIntrinsicMethod, IrExpr, IrModule, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/* Loop versions over the UTF-16 units of a string that is not ASCII. + * + * The runtime publishes the receiver of an indexed scan with its UTF-16 + * units (ScrStrScan, string-reads.ts), and drops them when the string is + * released or appended to in place, or when its index entry is evicted: + * the runtime indexes another non-ASCII string. A loop version's string + * receiver is a binding the loop does not write, which keeps it alive and + * unappended for the whole loop. Its fast copy over units therefore only + * needs a body that indexes no other string: the operations below, and + * calls of functions made of the same operations without string reads + * (`unitSafeFunctions`). */ + +const ARRAY_METHODS = new Set(["push", "pop", "length"]); + +/* The typed-array and Buffer methods a units copy may run: those that + * call no program code and read no checked-dynamic value or string. A + * comparator sort (sortCompare) calls back into the program, and a copy + * from a checked-dynamic source (setFromDyn) reads its elements through + * getters; either can index another string, which evicts the receiver's + * units while the loop reads them. */ +const UNIT_SAFE_BYTES_METHODS: ReadonlySet = new Set([ + "buffer", + "length", + "byteLength", + "byteOffset", + "get", + "slice", + "subarray", + "copyWithin", + "sortNumeric", + "toReversed", + "with", + "toArray", + "setFrom", + "readNum", + "writeNum", + "readNumVar", + "writeNumVar", + "equals", + "compareBuf", + "indexOfNum", + "lastIndexOfNum", + "includesNum", + "indexOfElem", + "lastIndexOfElem", + "includesElem", + "fill", + "fillNum", + "fillElem", + "copy", + "swap16", + "swap32", + "swap64", + "dataViewNew", + "dvGetUint8", + "dvGetInt8", + "dvGetUint16", + "dvGetInt16", + "dvGetUint32", + "dvGetInt32", + "dvGetFloat16", + "dvGetFloat32", + "dvGetFloat64", + "dvGetBigUint64Number", + "dvGetBigInt64Number", + "dvSetUint8", + "dvSetInt8", + "dvSetUint16", + "dvSetInt16", + "dvSetUint32", + "dvSetInt32", + "dvSetFloat16", + "dvSetFloat32", + "dvSetFloat64", +]); + +/** Whether `e` (or statement `s`) uses only operations that index no + * string: numbers, booleans, locals, records and class fields, typed + * arrays, number arrays and Math. `reads` admits the string reads a + * version covers; `calls` the callees. */ +function unitSafe( + node: { e: IrExpr } | { s: IrStmt }, + reads: (e: IrExpr & { kind: "strIntrinsic" }) => boolean, + calls: (callee: string) => boolean, +): boolean { + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + case "numLit": + case "boolLit": + case "bin": + case "unary": + case "logical": + case "ternary": + case "toBool": + case "seqExpr": + case "assignExpr": + case "incDec": + case "recordGet": + case "fieldGet": + break; + case "bytesIntrinsic": + if (!UNIT_SAFE_BYTES_METHODS.has(e.method)) return false; + break; + case "arrayGet": + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + break; + case "arrIntrinsic": { + // Growing or shrinking an array of numbers allocates; it reads no + // string. + const array = e.receiver.type; + if ( + !ARRAY_METHODS.has(e.method) || + array.kind !== "array" || + (array.elem.kind !== "f64" && array.elem.kind !== "bool") + ) + return false; + break; + } + case "libCall": + if (!e.fn.startsWith("math.")) return false; + break; + case "call": + if (!calls(e.callee)) return false; + break; + case "strIntrinsic": + if (!reads(e)) return false; + break; + default: + return false; + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "varDecl": + case "assign": + case "exprStmt": + case "if": + case "block": + case "while": + case "doWhile": + case "for": + case "break": + case "continue": + case "return": + case "recordSet": + case "fieldSet": + case "bytesSet": + break; + default: + return false; + } + return everyStmtChild(s, expr, stmt); + }; + return "e" in node ? expr(node.e) : stmt(node.s); +} + +/** The module's functions a units version may call: synchronous + * functions without captures whose bodies use only unit-safe operations, + * read no string, and call only such functions. */ +export function unitSafeFunctions(mod: IrModule): ReadonlySet { + const candidates = new Map( + mod.functions + .filter((fn) => !fn.async && !fn.generator && !(fn.captures?.length ?? 0)) + .map((fn) => [fn.name, fn]), + ); + const safe = new Set(candidates.keys()); + for (let settled = false; !settled;) { + settled = true; + for (const name of safe) { + const fn = candidates.get(name)!; + const ok = fn.body.every((s) => + unitSafe( + { s }, + () => false, + (callee) => safe.has(callee), + ), + ); + if (!ok) { + safe.delete(name); + settled = false; + } + } + } + return safe; +} + +/** The `length` reads of the binding `receiver` in a loop whose every + * part is unit-safe, reading only charCodeAt and length of `receiver` and + * calling only `safe` functions; null for any other loop. */ +export function unitSafeLoop( + loop: IrStmt & { kind: "for" | "while" }, + receiver: string, + safe: ReadonlySet, +): ReadonlySet | null { + const lengths = new Set(); + const reads = (e: IrExpr & { kind: "strIntrinsic" }): boolean => { + if (e.receiver.kind !== "varRef" || e.receiver.localId !== receiver) return false; + if (e.method === "length") lengths.add(e); + return e.method === "charCodeAt" || e.method === "length"; + }; + const calls = (callee: string): boolean => safe.has(callee); + return unitSafe({ s: loop }, reads, calls) ? lengths : null; +} diff --git a/packages/compiler/src/backend/llvm/unwind-abi.test.ts b/packages/compiler/src/backend/llvm/unwind-abi.test.ts new file mode 100644 index 000000000..5f7f5895b --- /dev/null +++ b/packages/compiler/src/backend/llvm/unwind-abi.test.ts @@ -0,0 +1,285 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; +import { UnwindAbiViolation, bridgePlainUnwindCalls } from "./unwind-abi.js"; + +async function moduleFor(source: string) { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unwind-abi-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The definition of the source function `name` (its i32 or borrowing entry when it has one). */ +function body(ir: string, name: string): string { + return new RegExp( + `^define internal [^\\n]*@sc_b?f_[^\\n(]*${name}(?:\\.i32)?\\([^]*?^}`, + "m", + ).exec(ir)![0]; +} + +const SOURCE = ` +function leaf(n: number): number { + if (n === 3) throw new RangeError("three"); + return n * 2; +} +function first(n: number): number { + return leaf(n) + 1; +} +function second(n: number): number { + return first(n) * 3; +} +function third(n: number): number { + return second(n) - 2; +} +function mid(n: number): number { + const parts: string[] = ["a" + n]; + const r = third(n); + parts.push("b"); + return r + parts.length; +} +class Counter { + total = 0; + add(n: number): void { this.total += mid(n); } +} +function outer(n: number): string { + try { return "ok " + mid(n); } catch (e) { return "caught " + (e as Error).message; } +} +const counter = new Counter(); +for (let i = 0; i < 5; i++) { console.log(outer(i)); try { counter.add(i); } catch { console.log("add", i); } } +const f = (x: number) => mid(x); +console.log(f(1), counter.total); +`; + +const LINUX = { targetTriple: "x86_64-unknown-linux-gnu", unwindAbi: true } as const; + +test("directly called functions raise, and their callers invoke them without a pending check", async () => { + const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX); + // leaf throws: the exception starts on the pending flag, and the three + // calls above it test the flag too; mid raises it on. + for (const name of ["leaf", "first", "second", "third"]) + expect(body(ir, name)).not.toContain("@scr_unwind_raise"); + const mid = body(ir, "mid"); + expect(mid).toMatch(/= call double @sc_b?f_[^(]*third\(/); + expect(mid).toContain("call void @scr_unwind_raise()"); + // outer invokes mid; the landing pad takes the exception to its handler. + const outer = body(ir, "outer"); + expect(outer).toMatch( + /= invoke double @sc_b?f_[^(]*mid[^\n]* to label %inv\.ok\d+ unwind label %inv\.lp\d+/, + ); + expect(outer).toMatch(/inv\.lp\d+:\n\s+%t\d+ = landingpad \{ ptr, i32 \} cleanup/); + expect(outer).toContain("personality ptr @__gcc_personality_v0"); + expect(ir).toContain("declare i32 @__gcc_personality_v0(...)"); + expect(ir).toContain("declare void @scr_unwind_raise() cold"); +}); + +test("a method of a class outside any hierarchy is on the unwinding ABI; closures keep the flag", async () => { + const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX); + // Counter.add invokes mid and resumes the unwind from its landing pad; the + // closure over mid is a pointer target and returns with the flag. + const add = body(ir, "Counter_add"); + expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/); + expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/); + expect(add).not.toContain("@scr_unwind_raise"); + const closures = ir + .split(/\n(?=define )/) + .filter((def) => /^define internal [^\n]*@sc_b?f_[^\n(]*fn_/.test(def)); + expect(closures.length).toBeGreaterThan(0); + for (const def of closures) expect(def).not.toContain("call void @scr_unwind_raise()"); +}); + +test("recursive functions keep the pending flag, also when they recurse through a callback", async () => { + const source = ` +function leaf(n: number): number { + if (n < 0) throw new RangeError("negative"); + return n; +} +function one(n: number): number { return leaf(n) + 1; } +function two(n: number): number { return one(n) + 1; } +function three(n: number): number { return two(n) + 1; } +function four(n: number): number { + const parts: string[] = ["a" + n]; + const r = three(n); + return r + parts.length; +} +function walk(n: number, depth: number): number { + const parts: string[] = ["w" + n]; + const r = depth === 0 ? four(n) : walk(n, depth - 1); + return r + parts.length; +} +function visit(n: number, depth: number, each: (n: number, depth: number) => number): number { + const parts: string[] = ["v" + n]; + const r = depth === 0 ? four(n) : each(n, depth - 1); + return r + parts.length; +} +const each = (n: number, depth: number): number => visit(n, depth, each); +for (let i = -1; i < 2; i++) { + try { console.log(walk(i, 3), visit(i, 3, each)); } catch (e) { console.log((e as Error).message); } +} +`; + const ir = emitLlvmModule(await moduleFor(source), LINUX); + expect(body(ir, "four")).toContain("call void @scr_unwind_raise()"); + for (const name of ["walk", "visit"]) { + expect(body(ir, name)).not.toContain("@scr_unwind_raise"); + expect(body(ir, name)).toMatch(/invoke double @sc_b?f_[^(]*four/); + } +}); + +test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => { + const mod = await moduleFor(SOURCE); + for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) { + const ir = emitLlvmModule(mod, options); + expect(ir).not.toContain("invoke "); + expect(ir).not.toContain("@scr_unwind_raise"); + expect(ir).not.toContain("personality"); + } +}); + +test("bridgePlainUnwindCalls keeps the pending-flag contract at plain calls of raising functions", () => { + const raising = [ + "define internal ptr @sc_bf_raises(ptr %p) #0 { ; raises", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const caller = [ + "define internal ptr @sc_f_adapter(ptr %p0) #0 {", + "entry:", + " %result = call ptr @sc_bf_raises(ptr %p0) ; borrowed result", + " br label %next", + "next:", + " %v = phi ptr [ %result, %entry ]", + " ret ptr %v", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([`${raising}\n${caller}`]); + expect(out).toContain( + "%result.ub = invoke ptr @sc_bf_raises(ptr %p0) to label %ub.n.0 unwind label %ub.lp.0", + ); + expect(out).toContain("%result = phi ptr [ %result.ub, %ub.n.0 ], [ null, %ub.lp.0 ]"); + // The split block's successor now comes from its last part. + expect(out).toContain("%v = phi ptr [ %result, %ub.ok.0 ]"); + expect(out).toMatch( + /define internal ptr @sc_f_adapter\(ptr %p0\) #0 personality ptr @__gcc_personality_v0 \{/, + ); + expect(out).toContain("declare i32 @__gcc_personality_v0(...)"); +}); + +test("an invoked wrapper of a raising function resumes the unwind; its address must not escape", () => { + const raising = [ + "define internal double @sc_bf_body.i32(i32 %a) #0 {", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const wrapper = [ + "define internal double @sc_bf_body(double %a) #0 {", + "entry:", + " %i = fptosi double %a to i32", + " %r = call double @sc_bf_body.i32(i32 %i)", + " ret double %r", + "}", + ].join("\n"); + const caller = [ + "define internal double @sc_bf_caller(double %a) #0 personality ptr @__gcc_personality_v0 {", + "entry:", + " %r = invoke double @sc_bf_body(double %a) to label %ok unwind label %lp", + "lp:", + " %x = landingpad { ptr, i32 } cleanup", + " call void @scr_unwind_raise()", + " unreachable", + "ok:", + " ret double %r", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([[raising, wrapper, caller].join("\n")]); + expect(out).toContain( + "%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0", + ); + expect(out).toContain("resume { ptr, i32 } %ub.v.0"); + expect(() => + bridgePlainUnwindCalls([ + `${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`, + ]), + ).toThrow(UnwindAbiViolation); + try { + bridgePlainUnwindCalls([ + `${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`, + ]); + } catch (error) { + expect([...(error as UnwindAbiViolation).symbols]).toEqual(["sc_bf_body.i32"]); + } +}); + +test("an invoked function that never tests the pending flag needs no raise", () => { + const raising = [ + "define internal void @sc_bf_raises() #0 {", + "entry:", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const leaf = [ + "define internal double @sc_bf_leaf() #0 {", + "entry:", + " ret double 1.0", + "}", + ].join("\n"); + const caller = (callee: string) => + [ + "define internal double @sc_bf_caller() #0 personality ptr @__gcc_personality_v0 {", + "entry:", + ` %r = invoke double @${callee}() to label %ok unwind label %lp`, + "lp:", + " %x = landingpad { ptr, i32 } cleanup", + " call void @scr_unwind_raise()", + " unreachable", + "ok:", + " ret double %r", + "}", + ].join("\n"); + expect(() => + bridgePlainUnwindCalls([[raising, leaf, caller("sc_bf_leaf")].join("\n")]), + ).not.toThrow(); + const testing = leaf.replace( + " ret double 1.0", + " %f = load i32, ptr @scr_exc_flag\n ret double 1.0", + ); + expect(() => + bridgePlainUnwindCalls([[raising, testing, caller("sc_bf_leaf")].join("\n")]), + ).toThrow(UnwindAbiViolation); +}); + +test("calls that cannot unwind are marked nounwind", () => { + const raising = [ + "define internal void @sc_bf_raises(ptr %b) #0 {", + "entry:", + " %n = call i64 @scr_bytes_length(ptr %b) ; length", + " call void @scr_bytes_release(ptr %b), !dbg !4", + " %r = call ptr %b(ptr %b)", + " call void @scr_unwind_raise()", + " unreachable", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([raising]); + expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length"); + expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4"); + expect(out).toContain("%r = call ptr %b(ptr %b) nounwind"); + expect(out).toContain(" call void @scr_unwind_raise()\n"); +}); diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts new file mode 100644 index 000000000..4dc611913 --- /dev/null +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -0,0 +1,491 @@ +/* The functions that propagate exceptions by unwinding instead of the + * pending flag (see unwindAbiFunctions). */ +import type { IrExpr, IrModule } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; + +/** How many calls above a `throw` functions keep the pending flag. */ +const THROW_LEVELS = 3; +/** The call graph's node for calls through values. */ +const ANY_FUNCTION = " any function"; + +/** The nodes of `edges` on a cycle (a recursion), by Tarjan's algorithm + * without recursion of its own: a large module's call chains are deeper + * than the host stack. */ +function recursiveFunctions(edges: ReadonlyMap): Set { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const result = new Set(); + let counter = 0; + const enter = (name: string): { name: string; next: number } => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + return { name, next: 0 }; + }; + for (const root of edges.keys()) { + if (index.has(root)) continue; + const work = [enter(root)]; + while (work.length > 0) { + const top = work[work.length - 1]!; + const out = edges.get(top.name) ?? []; + if (top.next < out.length) { + const callee = out[top.next++]!; + if (!index.has(callee)) work.push(enter(callee)); + else if (onStack.has(callee)) + low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!)); + continue; + } + work.pop(); + const parent = work[work.length - 1]; + if (parent !== undefined) + low.set(parent.name, Math.min(low.get(parent.name)!, low.get(top.name)!)); + if (low.get(top.name) !== index.get(top.name)) continue; + const members: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + members.push(member); + } while (member !== top.name); + if (members.length > 1 || out.includes(top.name)) for (const m of members) result.add(m); + } + } + return result; +} + +/** The may-throw functions whose exceptions reach their callers by + * unwinding the native stack (scr_unwind_raise) instead of returning with + * the pending flag set. Their callers call them with `invoke`, so the call + * needs no flag test; the landing pad runs the frame's unwind. + * + * A function qualifies only when every way it can be reached is a direct + * `call` from emitted code that can lower it as an `invoke`: it is not + * async or a generator, not a constructor, not a method a virtual call can + * reach or one a vtable holds (an overridden method), not a closure + * target, not the entry or a library export, not named by any node other + * than a `call`'s callee, and not called from an async or generator body + * (coroutine bodies keep plain calls). + * + * A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a + * few nanoseconds for a flag test, so the functions an exception is likely to + * cross many of keep the flag: those with a `throw` or rethrow of their own + * and those up to THROW_LEVELS calls above one (a handler near the throw + * takes it without a native unwind), and the recursive ones (deep stacks + * are recursions). */ +export function unwindAbiFunctions( + mod: IrModule, + mayThrow: ReadonlySet, + excluded: ReadonlySet = new Set(), +): Set { + const names = new Set(mod.functions.map((fn) => fn.name)); + const blocked = new Set(excluded); + blocked.add(mod.entry); + // The runtime calls the error property readers through the pointers the + // module installs (emit-module.ts). + blocked.add("%error.name.read"); + blocked.add("%error.message.read"); + for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName); + // Methods a virtual call can reach, and the overridden methods of class + // hierarchies (whose vtables list them), are called through pointers; the + // others are called directly, like functions. A virtual call reaches its listed + // targets, or else the method in its receiver class's hierarchy; a + // receiver that is no class of the module (an interface) may reach any + // class's method of that name. + const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls])); + const anyClass = new Set(); + for (const fn of mod.functions) + everyStmtList(fn.body, { + expr: (e: IrExpr) => { + if (e.kind !== "virtualCall") return true; + if (e.targets !== undefined) for (const target of e.targets) blocked.add(target); + else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`); + else anyClass.add(e.method); + return true; + }, + stmt: () => true, + }); + // A hierarchy's vtables hold the methods declared by more than one of its + // classes (an override and what it overrides). + const rootOf = (name: string): string => { + let cls = classes.get(name); + while (cls?.base !== undefined && classes.has(cls.base)) cls = classes.get(cls.base); + return cls?.name ?? name; + }; + const declarers = new Map(); + for (const cls of mod.classes ?? []) + for (const m of cls.methods ?? []) { + const key = `${rootOf(cls.name)}\0${m}`; + declarers.set(key, (declarers.get(key) ?? 0) + 1); + } + for (const cls of mod.classes ?? []) { + blocked.add(`%${cls.name}.constructor`); + blocked.add(`%${cls.name}.construct`); + const root = rootOf(cls.name); + for (const m of cls.methods ?? []) + if (anyClass.has(m) || declarers.get(`${root}\0${m}`)! > 1) blocked.add(`%${cls.name}.${m}`); + } + // Any function a node names other than as a call's callee may be reached + // through a pointer. + const referenced = new Set(); + const noteNames = (node: object, skip: string | undefined): void => { + for (const key in node) { + if (key === skip) continue; + const value = (node as Record)[key]; + if (typeof value === "string" && names.has(value)) referenced.add(value); + } + }; + // The call graph, for the functions near a throw and the recursive ones. + // Calls through values (closures, class values, dynamic calls, callbacks + // passed to the runtime) reach ANY_FUNCTION, which reaches every function + // a value can hold. + const throws = new Set(); + const callers = new Map(); + const callees = new Map(); + const edge = (from: string, to: string): void => { + let list = callers.get(to); + if (list === undefined) callers.set(to, (list = [])); + list.push(from); + let out = callees.get(from); + if (out === undefined) callees.set(from, (out = [])); + out.push(to); + }; + const methodsNamed = new Map(); + for (const cls of mod.classes ?? []) + for (const m of cls.methods ?? []) { + let list = methodsNamed.get(m); + if (list === undefined) methodsNamed.set(m, (list = [])); + list.push(`%${cls.name}.${m}`); + } + for (const fn of mod.functions) { + const coroutine = fn.async === true || fn.generator !== undefined; + everyStmtList(fn.body, { + expr: (e: IrExpr) => { + const kind = e.kind as string; + if (kind === "throw" || kind === "rethrow") throws.add(fn.name); + if (e.kind === "call") { + edge(fn.name, e.callee); + // The entry's callees (the module initializers) run once: on the + // flag they keep landing pads out of the top-level code inlined + // into the entry. + if (coroutine || fn.name === mod.entry) blocked.add(e.callee); + noteNames(e, "callee"); + return true; + } + noteNames(e, undefined); + if (e.kind === "virtualCall") + for (const target of e.targets ?? methodsNamed.get(e.method) ?? []) edge(fn.name, target); + else if (e.kind === "new") { + edge(fn.name, `%${e.className}.constructor`); + edge(fn.name, `%${e.className}.construct`); + } else if (kind === "callValue" || kind === "dynCall" || kind === "newValue") + edge(fn.name, ANY_FUNCTION); + else if ("args" in e) { + const args: unknown = e.args; + if (Array.isArray(args)) + for (const arg of args as IrExpr[]) + if (arg.type.kind === "func") edge(fn.name, ANY_FUNCTION); + } + return true; + }, + stmt: (s) => { + const kind = s.kind as string; + if (kind === "throw" || kind === "rethrow") throws.add(fn.name); + noteNames(s, undefined); + return true; + }, + }); + } + for (const name of referenced) { + blocked.add(name); + edge(ANY_FUNCTION, name); + } + // A throw starts on the pending flag, and so do the functions up to + // THROW_LEVELS calls above it. The union narrowing helpers' throws (a + // failed narrowing) don't count for their callers. + for (const name of throws) blocked.add(name); + let level = [...throws].filter((name) => !name.startsWith("%union.")); + const near = new Set(level); + for (let i = 0; i < THROW_LEVELS; i++) { + const next: string[] = []; + for (const name of level) + for (const caller of callers.get(name) ?? []) + if (!near.has(caller)) { + near.add(caller); + next.push(caller); + } + level = next; + } + for (const name of near) blocked.add(name); + for (const name of recursiveFunctions(callees)) blocked.add(name); + const result = new Set(); + for (const fn of mod.functions) { + if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue; + if (fn.async === true || fn.generator !== undefined) continue; + result.add(fn.name); + } + return result; +} + +/** What an unwinding-ABI function's unwind ends with when no handler of + * its own takes the exception. */ +export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable"; +/** What the unwind of a landing pad ends with when no handler of the + * function takes the exception: the unwind goes on (`resume`) with the + * exception the landing pad stored in UNWIND_SLOT. The block builder + * expands it (BlockBuilder.terminate). */ +export const UNWIND_RESUME = "resume { ptr, i32 } "; +/** The function's slot for the exception its landing pads take. */ +export const UNWIND_SLOT = "%unw.exc"; +/** scr_unwind_raise's declaration. It is not `noreturn`: sanitized builds + * would call the sanitizer's no-return hook before it, which fails on fiber + * stacks; scr_unwind_raise clears the unwound frames' shadow itself. */ +export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold"; + +/** The personality of functions with landing pads: the landing pads are + * cleanups that scr_unwind_raise's forced unwind runs. */ +export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0"; + +/** An unwind leaving the function: a raise, or a landing pad's `resume`. */ +const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /; +/** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */ +const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/; + +/** Functions emission found breaking the unwinding ABI's rules + * (bridgePlainUnwindCalls), by symbol: the caller emits the module again + * with them on the pending-flag ABI. */ +export class UnwindAbiViolation extends Error { + readonly symbols: ReadonlySet; + constructor(symbols: ReadonlySet) { + super(`unwinding ABI: ${[...symbols].slice(0, 3).join(", ")} must keep the pending flag`); + this.symbols = symbols; + } +} +const DEFINE_NAME = /^define [^@\n]*@([\w.$%-]+)\(/; +const PLAIN_CALL = /^(\s*)(%[\w.$-]+) = call (.*?)@([\w.$%-]+)\(|^(\s*)call (.*?)@([\w.$%-]+)\(/; +const RETURN_ATTRIBUTES = + /\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g; + +/** A call instruction's line. */ +const CALL_LINE = /^\s*(?:%[\w.$-]+ = )?(?:(?:tail|musttail|notail) )?call /; +/** A call's callee (a function or a register) and the parenthesis that + * opens its arguments. */ +const CALLEE = /[@%][\w.$%-]+\(/; + +/** The index of the parenthesis that closes the argument list opened at `open`. */ +function argumentsEnd(line: string, open: number): number { + let depth = 0; + let close = open; + for (; close < line.length; close++) { + const c = line.charAt(close); + if (c === "(") depth++; + else if (c === ")" && --depth === 0) break; + } + return close; +} + +/** A call that cannot unwind, marked so: only scr_unwind_raise and the + * raising functions propagate an unwind (runtime code and every function + * reached through a pointer return normally), so every other call is + * `nounwind`, and the inliner keeps it a call where it inlines a raising + * function at an invoke. */ +function markNounwind(line: string): string { + const call = line.indexOf("call "); + const callee = CALLEE.exec(line.slice(call)); + if (callee === null || callee[0] === "@scr_unwind_raise(") return line; + const close = argumentsEnd(line, call + callee.index + callee[0].length - 1); + return close >= line.length + ? line + : `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`; +} + +/** The value a bridged call yields on its landing pad: the dummy a + * pending-flag callee returns with an exception pending. */ +function dummyValue(type: string): string { + if (type === "ptr") return "null"; + if (type === "double" || type === "float") return "0.0"; + if (/^i\d+$/.test(type)) return "0"; + return "zeroinitializer"; +} + +/** Rewrites every remaining plain call of an unwinding-ABI function (one + * that raises, scr_unwind_raise, or resumes an unwind) in `parts`, the module's text, into an + * invoke whose landing pad continues after the call with the exception + * pending and the call's dummy result: the pending-flag contract those + * call sites were emitted for, whichever lowering emitted them. Calls of + * these functions that emitDirectCall lowered are invokes already. */ +export function bridgePlainUnwindCalls(parts: string[]): string[] { + const raisers = new Set(); + const split = parts.map((part) => part.split(/\n(?=define )/)); + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + if (m !== null && UNWINDS.test(chunk)) raisers.add(m[1]!); + } + if (raisers.size === 0) return parts; + // An invoke's callee must raise or never test the pending flag: one that + // returned with the flag pending would pass the exception by its invoking + // caller unseen. A wrapper an invoke reaches (an int32 or borrowed-ABI + // entry forwarding to the body) propagates its callee's unwind instead + // (`resume`), so it raises too. A raising function must be reached only by + // calls: a pointer to it (a vtable or table entry, a runtime callback) + // could be called without a landing pad. Functions that break either rule + // go back to the pending-flag ABI (UnwindAbiViolation). + const violations = new Set(); + const invoked = new Set(); + const testsFlag = new Set(); + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + if (m !== null && PENDING_TEST.test(chunk)) testsFlag.add(m[1]!); + for (const c of chunk.matchAll(/ invoke [^@\n]*@([\w.$%-]+)\(/g)) invoked.add(c[1]!); + } + const resumers = new Set(); + for (let grew = true; grew;) { + grew = false; + for (const chunks of split) + for (const chunk of chunks) { + const m = DEFINE_NAME.exec(chunk); + const name = m?.[1]; + if (name === undefined || raisers.has(name) || !invoked.has(name)) continue; + if (![...chunk.matchAll(/ call [^@\n]*@([\w.$%-]+)\(/g)].some((c) => raisers.has(c[1]!))) + continue; + if (testsFlag.has(name)) continue; + raisers.add(name); + resumers.add(name); + grew = true; + } + } + for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name); + for (const chunks of split) + for (const chunk of chunks) + for (const line of chunk.split("\n")) { + if (line.startsWith("define ")) continue; + for (const m of line.matchAll(/@([\w.$%-]+)/g)) { + if (!raisers.has(m[1]!)) continue; + const at = m.index!; + if ( + /(?:call|invoke) [^@]*$/.test(line.slice(0, at)) && + line.charAt(at + m[0].length) === "(" + ) + continue; + violations.add(m[1]!); + } + } + if (violations.size > 0) throw new UnwindAbiViolation(violations); + let personality = false; + let counter = 0; + const out = split.map((chunks) => + chunks + .map((chunk) => { + if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk; + const lines = chunk.split("\n"); + const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!); + const result: string[] = []; + // The label a block's terminator now follows, for phi operands. + const moved = new Map(); + let block = ""; + let changed = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const label = /^([\w.$-]+):/.exec(line); + if (label !== null) block = label[1]!; + const m = PLAIN_CALL.exec(line); + const callee = m === null ? undefined : (m[4] ?? m[7]); + if (m === null || callee === undefined || !raisers.has(callee)) { + const marked = CALL_LINE.test(line) ? markNounwind(line) : line; + changed ||= marked !== line; + result.push(marked); + continue; + } + // The call's text from its callee to the end of the argument list. + const close = argumentsEnd(line, line.indexOf(`@${callee}(`) + callee.length + 1); + const head = line.slice(line.indexOf("call ") + 5, close + 1); + const tail = line.slice(close + 1); + const meta = /,\s*!/.exec(tail); + const attrs = ( + meta === null ? tail.replace(/;.*$/, "") : tail.slice(0, meta.index) + ).trimEnd(); + const dbg = meta === null ? "" : tail.slice(meta.index).replace(/\s;.*$/, ""); + const n = counter++; + if (block === "") { + result.splice(1, 0, "entry.ub:"); + block = "entry.ub"; + } + const indent = m[1] ?? m[5] ?? " "; + const ok = `ub.ok.${n}`; + const pad = `ub.lp.${n}`; + if (resume) { + // The wrapper holds nothing to release: the unwind goes on to + // its caller's landing pad. + const assign = m[2] !== undefined ? `${m[2]} = ` : ""; + result.push( + `${indent}${assign}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`, + ); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}resume { ptr, i32 } %ub.v.${n}`, + ); + result.push(`${ok}:`); + } else if (m[2] !== undefined) { + const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim(); + const normal = `ub.n.${n}`; + result.push( + `${indent}${m[2]}.ub = invoke ${head}${attrs} to label %${normal} unwind label %${pad}${dbg}`, + ); + result.push(`${normal}:`, `${indent}br label %${ok}`); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}br label %${ok}`, + ); + result.push( + `${ok}:`, + `${indent}${m[2]} = phi ${type} [ ${m[2]}.ub, %${normal} ], [ ${dummyValue(type)}, %${pad} ]`, + ); + } else { + result.push( + `${indent}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`, + ); + result.push( + `${pad}:`, + `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, + `${indent}br label %${ok}`, + ); + result.push(`${ok}:`); + } + moved.set(block, ok); + changed = true; + } + if (!changed) return chunk; + if (moved.size === 0) return result.join("\n"); + personality = true; + const header = result[0]!; + const brace = header.lastIndexOf(" {"); + if (!header.includes("personality") && brace >= 0) { + // Before the function's metadata attachments (`!dbg`), if any. + const dbg = header.slice(0, brace).search(/\s!dbg !\d+$/); + const at = dbg >= 0 ? dbg : brace; + result[0] = `${header.slice(0, at)} ${UNWIND_PERSONALITY}${header.slice(at)}`; + } + // A split block's successors now come from its last part. + if (moved.size > 0) + for (let i = 0; i < result.length; i++) { + const line = result[i]!; + if (!line.includes(" phi ")) continue; + result[i] = line.replace(/, %([\w.$-]+) \]/g, (all, name: string) => { + const to = moved.get(name); + return to === undefined ? all : `, %${to} ]`; + }); + } + return result.join("\n"); + }) + .join("\n"), + ); + if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)"))) + out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`; + return out; +} diff --git a/packages/compiler/src/backend/llvm/wrapping-sums.ts b/packages/compiler/src/backend/llvm/wrapping-sums.ts index c736fcf72..5b487d809 100644 --- a/packages/compiler/src/backend/llvm/wrapping-sums.ts +++ b/packages/compiler/src/backend/llvm/wrapping-sums.ts @@ -4,13 +4,15 @@ import { integerArithmeticRange, type IntegerRange } from "../../ir/integer-rang import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import type { LlInteger } from "./integer-values.js"; -/** Marks the `+` and `-` operands whose number only reaches ToInt32/ToUint32 - * consumers (`| 0`, the other bitwise operators, `Math.imul`, `Math.clz32`), - * directly or through further sums, in the emitter's `wrappingSums`. Called - * by the consumer before its operands emit. */ +/** Marks the `+`, `-` and `*` operands whose number only reaches + * ToInt32/ToUint32 consumers (`| 0`, the other bitwise operators, + * `Math.imul`, `Math.clz32`), directly or through further sums and + * products, in the emitter's `wrappingSums`. Called by the consumer before + * its operands emit. */ export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrExpr[]): void { for (const operand of operands) { - if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-")) continue; + if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-" && operand.op !== "*")) + continue; if (operand.type.kind !== "f64" || host.wrappingSums.has(operand)) continue; host.wrappingSums.add(operand); markWrappingOperands(host, operand.left, operand.right); @@ -128,12 +130,15 @@ function wrappingOperand( return { low, valid }; } -/** ToUint32 of `left op right` (`+` or `-`) in 32-bit arithmetic when the - * expression only reaches ToInt32/ToUint32 consumers and both operands are - * exact integers or NaN with an exact result. Both conversions are - * arithmetic mod 2^32, so the wrapping i32 sum of the operands' low halves - * is the converted exact sum; a NaN operand makes the sum NaN, which - * converts to 0. Returns undefined, emitting nothing, for any other sum. */ +/** ToUint32 of `left op right` (`+`, `-` or `*`) in 32-bit arithmetic when + * the expression only reaches ToInt32/ToUint32 consumers and both operands + * are exact integers or NaN with an exact result. Both conversions are + * arithmetic mod 2^32, so the wrapping i32 sum or product of the operands' + * low halves is the converted exact result; a NaN operand makes the result + * NaN, which converts to 0. A product is exact when the operands' magnitude + * bounds multiply to at most 2^53; it may be -0 (`0 * -1`), which converts + * to 0 like the integer 0. Returns undefined, emitting nothing, for any + * other expression. */ export function emitWrappingSum( host: LlvmEmitterContext, e: IrExpr & { kind: "bin" }, @@ -142,15 +147,23 @@ export function emitWrappingSum( li: LlInteger | null, ri: LlInteger | null, ): string | undefined { - if ((e.op !== "+" && e.op !== "-") || !host.wrappingSums.has(e)) return undefined; + if ((e.op !== "+" && e.op !== "-" && e.op !== "*") || !host.wrappingSums.has(e)) return undefined; const leftRange = li ? li.range : integerOrNaNRange(host, e.left); const rightRange = leftRange && (ri ? ri.range : integerOrNaNRange(host, e.right)); - if (!rightRange || !integerArithmeticRange(e.op, leftRange!, rightRange)) return undefined; + if (!rightRange) return undefined; + if ( + e.op === "*" + ? !exactProduct(leftRange!, rightRange) + : !integerArithmeticRange(e.op, leftRange!, rightRange) + ) + return undefined; const B = host.B; const left = wrappingOperand(host, l, li, leftRange!); const right = wrappingOperand(host, r, ri, rightRange); const sum = B.tmp(); - B.line(`${sum} = ${e.op === "+" ? "add" : "sub"} i32 ${left.low}, ${right.low}`); + B.line( + `${sum} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} i32 ${left.low}, ${right.low}`, + ); let valid = left.valid ?? right.valid; if (valid === null) return sum; if (left.valid && right.valid) { @@ -162,6 +175,17 @@ export function emitWrappingSum( return out; } +/** Whether every product of a value in `left` and one in `right` is an + * integer of magnitude below 2^53, which the double product holds exactly. + * The bound's own product is computed in a double: a bound of at least + * 2^53 never rounds below 2^53, while 2^53 + 1 (3 * 3002399751580331) + * rounds to 2^53, so the test is strict. */ +function exactProduct(left: IntegerRange, right: IntegerRange): boolean { + const magnitude = (range: IntegerRange): number => + Math.max(Math.abs(range.min), Math.abs(range.max)); + return magnitude(left) * magnitude(right) < 2 ** 53; +} + /** The number locals of `fn` that only hold exact integers (never -0) or NaN, with the range of * those integers: locals declared once with an initializer, neither parameters nor captured or * boxed, whose initializer and every later write `integerOrNaNRange` covers. A local joins once diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 0cfab9244..693d20b3b 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -93,7 +93,7 @@ export function computeMayThrow( const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls])); const constructorClasses = new Map(); const classValueCallers = new Map>(); - const tdzGlobals = (mod.globals ?? []).filter((g) => g.tdz).map((g) => g.id); + const tdzGlobals = new Set((mod.globals ?? []).filter((g) => g.tdz).map((g) => g.id)); for (const cls of mod.classes ?? []) { constructorClasses.set(`%${cls.name}.constructor`, cls.name); for (const m of cls.methods ?? []) { @@ -113,11 +113,10 @@ export function computeMayThrow( if (explain) f.calleeLocs = new Map(); // TDZ reads and non-initializing writes can throw ReferenceError. // Capture locals carry the same flag as the declaring binding. - const tdzIds = new Set([...tdzGlobals, ...fn.locals.filter((l) => l.tdz).map((l) => l.id)]); - const mutableTdzIds = new Set([ - ...tdzGlobals, - ...fn.locals.filter((l) => l.tdz && l.mutable).map((l) => l.id), - ]); + // Every TDZ global counts for both reads and writes; the module's set is + // shared by every function. + const tdzLocals = new Set(fn.locals.filter((l) => l.tdz).map((l) => l.id)); + const mutableTdzLocals = new Set(fn.locals.filter((l) => l.tdz && l.mutable).map((l) => l.id)); // Traverse typed executable nodes without copying the IR into unknown. const visit = (rec: IrExpr | IrStmt): boolean => { const threw = f.throws; @@ -133,13 +132,17 @@ export function computeMayThrow( case "varRef": case "incDec": case "assignExpr": - if (tdzIds.has(rec.localId)) f.throws = true; + if (tdzGlobals.has(rec.localId) || tdzLocals.has(rec.localId)) f.throws = true; break; case "assign": // A declaration is allowed to fill an empty box. Legacy const // TDZ stores also initialize; only mutable subsequent stores // introduce the new write-side exception edge. - if (rec.initializes !== true && mutableTdzIds.has(rec.localId)) f.throws = true; + if ( + rec.initializes !== true && + (tdzGlobals.has(rec.localId) || mutableTdzLocals.has(rec.localId)) + ) + f.throws = true; break; case "dynCheck": case "caughtCheck": diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index ceb162bcc..1364bca4d 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -43,6 +43,7 @@ import { cacheTargetIdentity, isZigDriver, isMuslTarget, + type CcDriver, } from "./driver.js"; import { resolveProgramShardMergeIdentity, @@ -821,6 +822,7 @@ export async function compileCInternal( // generated/native input that references inflate symbols. Cross // and targetless Zig builds use vendored zlib objects in the input section above. ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), // glibc keeps libm separate from libc. This must trail the generated // program and every native FFI input because GNU ld resolves archives // from left to right. @@ -1068,6 +1070,7 @@ export async function compileCInternal( ...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []), ...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []), ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), ...driver.linkArgs, ...executableLinkFlags, ]; @@ -1080,6 +1083,7 @@ export async function compileCInternal( ...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []), ...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []), ...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []), + ...unwinderLinkArgs(driver), ...driver.linkArgs, ...executableLinkFlags, ]; @@ -1689,3 +1693,11 @@ export async function compileC(opts: CcOptions): Promise { await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true }); } } + +/** Linux runtimes raise through the native unwinder (scr_unwind_raise): + * Zig's libunwind, or the toolchain's libgcc_eh. Both are static archives, + * so programs load no shared library for them. */ +function unwinderLinkArgs(driver: CcDriver): string[] { + if (targetPlatform(driver) !== "linux") return []; + return isZigDriver(driver) ? ["-lunwind"] : ["-lgcc_eh"]; +} diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index c7c5bc32b..d32867331 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -53,6 +53,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [ "scr_object.c", "scr_union.c", "scr_exception.c", + "scr_unwind.c", "scr_error.c", "scr_console.c", "scr_lib.c", diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts index 20214818f..293d814b8 100644 --- a/packages/compiler/src/backend/runtime-pack-native.test.ts +++ b/packages/compiler/src/backend/runtime-pack-native.test.ts @@ -1,12 +1,39 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { nativeCodegenDiag } from "../diagnostics/diagnostic.js"; +import { compilerReleaseVersion } from "../library/sidecar.js"; import type { NativeLinkFeatures } from "./native-link-info.js"; -import { RuntimePackError } from "./runtime-pack-core.js"; -import { selectNativeRuntimePack } from "./runtime-pack-native.js"; -import { WASM32_WASI_TARGET } from "./targets.js"; +import { RuntimePackError, type RuntimePackManifest } from "./runtime-pack-core.js"; +import { + runtimePackManifest, + selectNativeRuntimePack, + stageNativeRuntimeSelection, + type NativeRuntimeSelection, + type RuntimePackStore, +} from "./runtime-pack-native.js"; +import { MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js"; + +vi.mock("node:crypto", async (original) => { + const crypto = await original(); + return { ...crypto, createHash: vi.fn(crypto.createHash) }; +}); +vi.mock("node:fs", async (original) => { + const fs = await original(); + return { ...fs, readFileSync: vi.fn(fs.readFileSync) }; +}); const dirs: string[] = []; afterEach(async () => @@ -45,3 +72,171 @@ test("a missing native helper keeps the reinstall hint", () => { "reinstall scriptc with optional dependencies enabled for this host", ); }); + +/** A runtime pack with one runtime object and a license, and a store that + * keeps entries in memory. */ +async function packFixture() { + const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-store-")); + dirs.push(root); + const target = MACOS_ARM64_TARGET; + const version = compilerReleaseVersion(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: target.runtimePackPackage, version }), + ); + mkdirSync(join(root, "artifacts")); + const object = join(root, "artifacts", "base.o"); + writeFileSync(object, "base object"); + writeFileSync(join(root, "license.txt"), "license"); + const unit = { + source: "scr_base.c", + predicate: true, + variants: [ + { + id: "default", + when: {}, + defines: [], + path: "artifacts/base.o", + sha256: createHash("sha256").update("base object").digest("hex"), + size: Buffer.byteLength("base object"), + }, + ], + }; + const manifest: RuntimePackManifest = { + schema: "scriptc.runtime-pack.v1", + format: 1, + package: target.runtimePackPackage, + version, + target: { + name: target.name, + llvm_triple: target.llvmTriple, + architecture: target.architecture, + object_format: target.objectFormat, + minimum_os: target.minimumOs, + }, + runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" }, + compiler: { command: "clang", identity: "fixture clang", target: target.llvmTriple }, + macros: { + executable: [], + excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT"], + sanitizer: "external-toolchain-required", + }, + flavors: { + release: { optimization: "-O2", runtime_units: [unit] }, + dev: { optimization: "-O0", runtime_units: [unit] }, + }, + archives: [], + system_libraries: [{ name: "System", predicate: true }], + licenses: [{ path: "license.txt", license: "fixture" }], + }; + const manifestText = JSON.stringify(manifest); + writeFileSync(join(root, "runtime-pack.json"), manifestText); + const entries = new Map(); + const store: RuntimePackStore = { + read: (key) => entries.get(key) ?? null, + write: (key, text) => void entries.set(key, text), + }; + const select = () => + selectNativeRuntimePack( + root, + target, + version, + {} as NativeLinkFeatures, + "release", + "executable", + store, + ); + let stages = 0; + const stage = (selection: NativeRuntimeSelection) => { + const directory = join(root, `stage-${stages++}`); + mkdirSync(directory); + return stageNativeRuntimeSelection(selection, directory, store); + }; + return { root, object, manifestText, entries, select, stage }; +} + +test("a stored selection answers while the pack's files keep their identities", async () => { + const f = await packFixture(); + const first = f.select(); + expect(first.selected.systemLibraries).toEqual(["System"]); + expect(f.entries.size).toBe(1); + // The next selection reads the stored answer instead of the manifest. + const [key, text] = [...f.entries][0]!; + const stored = JSON.parse(text); + stored.selected.systemLibraries = ["Stored"]; + f.entries.set(key, JSON.stringify(stored)); + const second = f.select(); + expect(second.selected.systemLibraries).toEqual(["Stored"]); + expect(second.licenses).toEqual(first.licenses); + expect(second.manifest).toBeUndefined(); + expect(runtimePackManifest(second).flavors.release).toEqual(first.manifest!.flavors.release); + // Writing the manifest again, with the same text, gives it a new identity. + writeFileSync(join(f.root, "runtime-pack.json"), f.manifestText); + expect(f.select().selected.systemLibraries).toEqual(["System"]); +}); + +test.each(["replaced", "rewritten in place"])( + "staging hashes an artifact again when it is %s, even at the same size and mtime", + async (change) => { + const f = await packFixture(); + const selection = f.select(); + f.stage(selection); + const before = statSync(f.object); + const changed = change === "replaced" ? f.object + ".new" : f.object; + writeFileSync(changed, "x".repeat(before.size)); + utimesSync(changed, before.atime, before.mtime); + if (changed !== f.object) renameSync(changed, f.object); + expect(() => f.stage(selection)).toThrow( + "runtime pack artifact hash mismatch: artifacts/base.o", + ); + }, +); + +test("staging does not hash an unchanged artifact again", async () => { + const f = await packFixture(); + const selection = f.select(); + const hashes = () => vi.mocked(createHash).mock.calls.length; + let start = hashes(); + f.stage(selection); + const firstStage = hashes() - start; + start = hashes(); + const staged = f.stage(selection); + // Only the store key is hashed; the staged copy has the verified bytes. + expect(hashes() - start).toBe(firstStage - 1); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); +}); + +test.skipIf(process.platform === "win32")( + "staging reads an artifact's bytes from the file whose identity it checks", + async () => { + const f = await packFixture(); + // The artifact path is a symbolic link to the verified file. + const real = f.object + ".real"; + renameSync(f.object, real); + symlinkSync(real, f.object); + const selection = f.select(); + f.stage(selection); + // During the next read the link names another file of the same size, + // and it names the verified file again before the read ends. + const other = f.object + ".other"; + writeFileSync(other, "x".repeat(statSync(real).size)); + const read = vi.mocked(readFileSync); + const original = read.getMockImplementation()!; + read.mockImplementation(((source: Parameters[0], ...rest: unknown[]) => { + if (typeof source === "string" && !source.endsWith("base.o")) + return (original as (...args: unknown[]) => unknown)(source, ...rest); + read.mockImplementation(original); + unlinkSync(f.object); + symlinkSync(other, f.object); + try { + return (original as (...args: unknown[]) => unknown)(source, ...rest); + } finally { + unlinkSync(f.object); + symlinkSync(real, f.object); + } + }) as typeof readFileSync); + const staged = f.stage(selection); + expect(read.getMockImplementation()).toBe(original); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); + }, +); diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index bd38e2ef0..0fff34455 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -1,16 +1,21 @@ /** Synchronous runtime-pack host for the statically compiled driver. Each - * selected artifact is hashed and copied from the same read into private - * storage. This host has no persistent cache or mutable installed link inputs. */ + * selected artifact is verified and copied from the same read into private + * storage. With a store, a pack's selections and the identities of the + * artifacts it verified are kept between builds: an artifact whose file + * identity is unchanged since it was hashed is not hashed again. */ import type { NativeOptimization } from "./optimization.js"; import { createHash } from "node:crypto"; import { closeSync, constants, existsSync, + fstatSync, mkdirSync, openSync, readFileSync, + statSync, writeSync, + type Stats, } from "node:fs"; import { dirname, join } from "node:path"; import type { NativeLinkFeatures } from "./native-link-info.js"; @@ -34,13 +39,66 @@ export interface NativeRuntimePack { export interface NativeRuntimeSelection { root: string; - manifest: RuntimePackManifest; + /** The parsed manifest; a selection read from a store has none until + * runtimePackManifest parses it. */ + manifest?: RuntimePackManifest; + licenses: RuntimePackManifest["licenses"]; packageText: string; manifestText: string; selected: RuntimePackArtifacts; flavor: NativeOptimization; } +/** Text entries kept between builds (the native compiler's cache). */ +export interface RuntimePackStore { + read(key: string): string | null; + write(key: string, text: string): void; +} + +interface FileIdentity { + dev: number; + ino: number; + size: number; + mtime: number; + ctime: number; +} + +function fileIdentity(path: string): FileIdentity { + return statsIdentity(statSync(path)); +} + +function statsIdentity(info: Stats): FileIdentity { + return { + dev: info.dev, + ino: info.ino, + size: info.size, + mtime: info.mtimeMs, + ctime: info.ctimeMs, + }; +} + +function sameIdentity(a: FileIdentity, b: FileIdentity): boolean { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mtime === b.mtime && + a.ctime === b.ctime + ); +} + +function storeKey(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +/** A stored selection: what selecting from the pack's files, with these + * identities, returned. */ +interface StoredSelection { + schema: 1; + licenses: RuntimePackManifest["licenses"]; + selected: RuntimePackArtifacts; +} + export function selectNativeRuntimePack( root: string, target: NativeTargetSpec, @@ -48,38 +106,204 @@ export function selectNativeRuntimePack( features: NativeLinkFeatures, flavor: NativeOptimization, mode: RuntimePackMode = "executable", + store?: RuntimePackStore, ): NativeRuntimeSelection { - if (!existsSync(join(root, "package.json"))) { + const packagePath = join(root, "package.json"); + const manifestPath = join(root, "runtime-pack.json"); + if (!existsSync(packagePath)) { throw new RuntimePackError( `runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`, "missing", "scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project", ); } - const packageText = readFileSync(join(root, "package.json"), "utf8"); - const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8"); + // The pack's files by identity, with everything else selection reads. + let key: string | null = null; + let identities: FileIdentity[] = []; + if (store !== undefined) { + try { + identities = [fileIdentity(packagePath), fileIdentity(manifestPath)]; + key = storeKey( + JSON.stringify({ + schema: 1, + root, + identities, + target: target.name, + compilerVersion, + features, + flavor, + mode, + }), + ); + const stored = readStoredSelection(store.read(key)); + if (stored !== null) { + const packageText = readFileSync(packagePath, "utf8"); + const manifestText = readFileSync(manifestPath, "utf8"); + // The texts read are the ones with the stored identities. + if ( + sameIdentity(fileIdentity(packagePath), identities[0]!) && + sameIdentity(fileIdentity(manifestPath), identities[1]!) + ) { + return { + root, + licenses: stored.licenses, + packageText, + manifestText, + selected: stored.selected, + flavor, + }; + } + } + } catch { + key = null; + } + } + const packageText = readFileSync(packagePath, "utf8"); + const manifestText = readFileSync(manifestPath, "utf8"); const identity = JSON.parse(packageText) as { name?: string; version?: string }; const manifest = parseRuntimePackManifest(JSON.parse(manifestText)); validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion); const selected = selectRuntimePackArtifacts(manifest, features, flavor, process.env, mode); - return { root, manifest, packageText, manifestText, selected, flavor }; + if (store !== undefined && key !== null) { + try { + // Stored only when the files selected from still have the identities + // in the key. + if ( + sameIdentity(fileIdentity(packagePath), identities[0]!) && + sameIdentity(fileIdentity(manifestPath), identities[1]!) + ) { + const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected }; + store.write(key, JSON.stringify(entry)); + } + } catch { + /* A failed write is a later miss. */ + } + } + return { + root, + manifest, + licenses: manifest.licenses, + packageText, + manifestText, + selected, + flavor, + }; } -function stageArtifact(root: string, stage: string, artifact: RuntimePackArtifact): string { +/** The selection's parsed manifest. */ +export function runtimePackManifest(selection: NativeRuntimeSelection): RuntimePackManifest { + return (selection.manifest ??= parseRuntimePackManifest(JSON.parse(selection.manifestText))); +} + +function readStoredSelection(text: string | null): StoredSelection | null { + if (text === null) return null; + const value: unknown = JSON.parse(text); + if (value === null || typeof value !== "object") return null; + const entry = value as Partial; + if ( + entry.schema !== 1 || + !Array.isArray(entry.licenses) || + entry.selected === undefined || + entry.selected === null || + typeof entry.selected !== "object" || + !Array.isArray(entry.selected.runtime) || + !Array.isArray(entry.selected.archives) || + !Array.isArray(entry.selected.systemLibraries) + ) + return null; + return entry as StoredSelection; +} + +/** An artifact path's identity when its bytes last matched the manifest's + * size and digest. */ +interface VerifiedArtifact { + path: string; + sha256: string; + dev: number; + ino: number; + size: number; + mtime: number; + ctime: number; +} + +interface StoredVerification { + schema: number; + artifacts: VerifiedArtifact[]; +} + +function readVerified( + store: RuntimePackStore | undefined, + key: string, +): Map { + const verified = new Map(); + try { + const text = store?.read(key) ?? null; + if (text === null) return verified; + const stored = JSON.parse(text) as StoredVerification; + if (stored.schema === 1) + for (const artifact of stored.artifacts) verified.set(artifact.path, artifact); + } catch { + /* A malformed entry verifies everything again. */ + } + return verified; +} + +function stageArtifact( + root: string, + stage: string, + artifact: RuntimePackArtifact, + verified: Map, + newlyVerified: VerifiedArtifact[], +): string { const source = join(root, artifact.path); const destination = join(stage, artifact.path); let bytes: Buffer; + let before: FileIdentity; + let after: FileIdentity; try { - bytes = readFileSync(source); + // The identities are those of the open file the bytes come from, also + // when the path is a symbolic link that changes during the read. + const input = openSync(source, constants.O_RDONLY); + try { + before = statsIdentity(fstatSync(input)); + bytes = readFileSync(input); + after = statsIdentity(fstatSync(input)); + } finally { + closeSync(input); + } } catch { throw new RuntimePackError(`runtime pack artifact is missing: ${artifact.path}`, "invalid"); } + // The bytes are those last hashed when the file kept the identity it had + // then for the whole read. + const known = verified.get(artifact.path); + const unchanged = sameIdentity(before, after); + const trusted = + known !== undefined && + known.sha256 === artifact.sha256 && + known.size === artifact.size && + unchanged && + known.dev === before.dev && + known.ino === before.ino && + known.size === before.size && + known.mtime === before.mtime && + known.ctime === before.ctime; if ( bytes.length !== artifact.size || - createHash("sha256").update(bytes).digest("hex") !== artifact.sha256 + (!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) ) { throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid"); } + if (!trusted && unchanged) + newlyVerified.push({ + path: artifact.path, + sha256: artifact.sha256, + dev: before.dev, + ino: before.ino, + size: before.size, + mtime: before.mtime, + ctime: before.ctime, + }); mkdirSync(dirname(destination), { recursive: true }); const fd = openSync( destination, @@ -123,15 +347,19 @@ export function stageNativeRuntimePack( export function stageNativeRuntimeSelection( selection: NativeRuntimeSelection, stageRoot: string, + store?: RuntimePackStore, ): NativeRuntimePack { - const { root, manifest, selected, packageText, manifestText } = selection; + const { root, licenses, selected, packageText, manifestText } = selection; const packagePath = join(root, "package.json"); const manifestPath = join(root, "runtime-pack.json"); - const runtimeObjects = selected.runtime.map((artifact) => - stageArtifact(root, stageRoot, artifact), - ); - const archives = selected.archives.map((artifact) => stageArtifact(root, stageRoot, artifact)); - for (const license of manifest.licenses) { + const key = storeKey(JSON.stringify({ schema: 1, verified: root })); + const verified = readVerified(store, key); + const newlyVerified: VerifiedArtifact[] = []; + const stage = (artifact: RuntimePackArtifact): string => + stageArtifact(root, stageRoot, artifact, verified, newlyVerified); + const runtimeObjects = selected.runtime.map(stage); + const archives = selected.archives.map(stage); + for (const license of licenses) { try { readFileSync(join(root, license.path)); } catch { @@ -144,5 +372,14 @@ export function stageNativeRuntimeSelection( ) { throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid"); } + if (store !== undefined && newlyVerified.length > 0) { + for (const artifact of newlyVerified) verified.set(artifact.path, artifact); + const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] }; + try { + store.write(key, JSON.stringify(entry)); + } catch { + /* A failed write only means hashing again next time. */ + } + } return { runtimeObjects, archives, systemLibraries: selected.systemLibraries }; } diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 2fd8b6fb0..55c3e9047 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -622,7 +622,7 @@ export function unsupportedTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnosti code: "SC2001", message: `values of type '${typeText}' cannot be compiled yet ` + - `(supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown')`, + `(supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown')`, loc, milestone: "M2", }; diff --git a/packages/compiler/src/frontend/input-tracker.test.ts b/packages/compiler/src/frontend/input-tracker.test.ts index b4f3957f5..d6f9a91b7 100644 --- a/packages/compiler/src/frontend/input-tracker.test.ts +++ b/packages/compiler/src/frontend/input-tracker.test.ts @@ -270,3 +270,49 @@ test("synchronous tracking restores parents after throws and propagates unstable trackedFileExists(join(dir, "outside.ts")); expect(parent.snapshot()).toEqual(before); }); + +test("a settled file is checked by its identity, and any write invalidates it", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); + scratch.push(dir); + const source = join(dir, "entry.ts"); + await writeFile(source, "export const answer = 1;\n"); + const written = fs.statSync(source); + // The file was last changed long enough before the read. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5000); + const tracker = new FrontendInputTracker(); + try { + tracker.run(() => trackedReadFile(source)); + } finally { + vi.useRealTimers(); + } + const snapshot = tracker.snapshot(); + const probe = snapshot.probes.find((entry) => entry.op === "file"); + expect(probe?.op === "file" && probe.identity !== undefined).toBe(true); + expect(validFrontendInputSnapshot(snapshot)).toBe(true); + const reads = vi.mocked(fs.readFileSync); + reads.mockClear(); + expect(frontendInputsStillMatch(snapshot)).toBe(true); + expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(0); + // A same-size rewrite that restores the modification time still changes + // the change time. + await writeFile(source, "export const answer = 2;\n"); + fs.utimesSync(source, written.atime, written.mtime); + expect(frontendInputsStillMatch(snapshot)).toBe(false); +}); + +test("a file changed shortly before the read is hashed on every check", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); + scratch.push(dir); + const source = join(dir, "entry.ts"); + await writeFile(source, "export const answer = 1;\n"); + const tracker = new FrontendInputTracker(); + tracker.run(() => trackedReadFile(source)); + const snapshot = tracker.snapshot(); + const probe = snapshot.probes.find((entry) => entry.op === "file"); + expect(probe?.op === "file" && probe.identity).toBeUndefined(); + const reads = vi.mocked(fs.readFileSync); + reads.mockClear(); + expect(frontendInputsStillMatch(snapshot)).toBe(true); + expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(1); +}); diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index d27ab6285..c23a87313 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -12,7 +12,8 @@ import { observeSourceText } from "./ts7/ast-node.js"; * behind an old resolution answer. */ export type FrontendInputProbe = - | { op: "file"; path: string; digest: string } + /** `identity` (fileIdentity) lets a later check skip hashing the file. */ + | { op: "file"; path: string; digest: string; identity?: string } | { op: "read-error"; path: string } | { op: "kind"; path: string; kind: "file" | "directory" | "other" | "missing" } | { op: "entries"; path: string; files: string[]; directories: string[] } @@ -53,6 +54,36 @@ function frontendSourceDigest(text: string): string { return createHash("sha256").update(text).digest("hex"); } +/** How long a file must have been unchanged before a read for its identity + * to stand for its content: a later write then gets a newer change time + * even on file systems whose timestamps tick once a second or every two. */ +const IDENTITY_SETTLE_MS = 2000; + +/** The device, inode, size, modification time and change time of `path`. A + * write changes the change time, which no tool can set back, so an equal + * identity means the content is the one hashed with it. */ +function fileIdentity(path: string): string | null { + try { + const info = statSync(path); + return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`; + } catch { + return null; + } +} + +/** The text of `path` with its identity when the identity was the same before + * and after the read and the file had been unchanged for IDENTITY_SETTLE_MS. */ +function readWithIdentity(path: string): { text: string; identity: string | null } { + const readAt = Date.now(); + const before = statSync(path); + const text = readFileSync(path, "utf8"); + const after = fileIdentity(path); + const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`; + const settled = + before.mtimeMs <= readAt - IDENTITY_SETTLE_MS && before.ctimeMs <= readAt - IDENTITY_SETTLE_MS; + return { text, identity: settled && after === identity ? identity : null }; +} + function pathKind(path: string): Extract["kind"] { try { const info = statSync(path); @@ -199,8 +230,11 @@ export function trackedReadFile(path: string): string | null { const virtual = virtualSources.get(path); if (virtual !== undefined) return virtual; try { - const text = readFileSync(path, "utf8"); - record({ op: "file", path, digest: frontendSourceDigest(text) }); + const { text, identity } = readWithIdentity(path); + const digest = frontendSourceDigest(text); + record( + identity === null ? { op: "file", path, digest } : { op: "file", path, digest, identity }, + ); return text; } catch { record({ op: "read-error", path }); @@ -337,6 +371,9 @@ export function frontendInputsStillMatch( } switch (probe.op) { case "file": { + // An unchanged identity stands for the content hashed with it. + if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity) + return true; try { return frontendSourceDigest(readFileSync(probe.path, "utf8")) === probe.digest; } catch { @@ -443,6 +480,11 @@ export function frontendInputsSemanticallyMatch( probes.push(probe); continue; } + if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity) { + currentSources.set(probe.path, previous); + probes.push(probe); + continue; + } let current: string; try { current = readFileSync(probe.path, "utf8"); @@ -460,7 +502,7 @@ export function frontendInputsSemanticallyMatch( if (rawText.has(probe.path) || !isSemanticEquivalent(probe.path, previous, current)) return null; changed.push({ path: probe.path, previous, current }); - probes.push({ ...probe, digest: currentDigest }); + probes.push({ op: "file", path: probe.path, digest: currentDigest }); } const adjusted: FrontendInputSnapshot = { ...snapshot, probes }; if (!frontendInputsStillMatch(adjusted, exclusions)) return null; @@ -488,7 +530,11 @@ export function validFrontendInputSnapshot(snapshot: unknown): snapshot is Front if (typeof value.path !== "string" || typeof value.op !== "string") return false; switch (value.op) { case "file": - return typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest); + return ( + typeof value.digest === "string" && + /^[0-9a-f]{64}$/.test(value.digest) && + (value.identity === undefined || typeof value.identity === "string") + ); case "read-error": return true; case "kind": diff --git a/packages/compiler/src/frontend/lowering/abstract-equality.ts b/packages/compiler/src/frontend/lowering/abstract-equality.ts index 6c74b3546..634ff6f22 100644 --- a/packages/compiler/src/frontend/lowering/abstract-equality.ts +++ b/packages/compiler/src/frontend/lowering/abstract-equality.ts @@ -5,6 +5,7 @@ import { DYN, DYN_HANDLE_KINDS, F64, + STRING, type IrExpr, type IrStmt, type IrType, @@ -285,6 +286,43 @@ export function lowerAbstractEquality( }; return negated ? { kind: "unary", op: "!", operand: result, type: BOOL, loc } : result; } + // Two Dates are equal when they are the same object, as with ===. + if (left.type.kind === "date" && right.type.kind === "date") + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + // A Date and a string or number compare through the Date's default + // primitive, its toString() text. + if (left.type.kind === "date" || right.type.kind === "date") { + const otherType = left.type.kind === "date" ? right.type : left.type; + if (otherType.kind === "string" || otherType.kind === "f64") { + // Both operands evaluate before the Date converts. + const l = lowerer.declareHiddenLocal("%looseA", left.type); + const r = lowerer.declareHiddenLocal("%looseB", right.type); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: l.id, init: left, loc }, + { kind: "varDecl", localId: r.id, init: right, loc }, + ]; + const primitive = (value: IrExpr): IrExpr => { + if (value.type.kind !== "date") return value; + const text: IrExpr = { + kind: "libCall", + fn: "date.toString", + args: [value], + type: STRING, + loc, + }; + return otherType.kind === "string" + ? text + : { kind: "libCall", fn: "num.fromString", args: [text], type: F64, loc }; + }; + const a = primitive(varRef(l.id, left.type, loc)); + const b = primitive(varRef(r.id, right.type, loc)); + const result: IrExpr = + otherType.kind === "string" + ? { kind: "strEq", negated, left: a, right: b, type: BOOL, loc } + : { kind: "bin", op: negated ? "!==" : "===", left: a, right: b, type: BOOL, loc }; + return { kind: "seqExpr", stmts, result, type: BOOL, loc }; + } + } if (!supportsPair(lowerer, left.type, right.type)) return null; if (left.kind === "strLit" && right.kind === "strLit") { return { kind: "boolLit", value: (left.value === right.value) !== negated, type: BOOL, loc }; diff --git a/packages/compiler/src/frontend/lowering/builtins/date-classes.ts b/packages/compiler/src/frontend/lowering/builtins/date-classes.ts index a2b0fc20a..9d152de56 100644 --- a/packages/compiler/src/frontend/lowering/builtins/date-classes.ts +++ b/packages/compiler/src/frontend/lowering/builtins/date-classes.ts @@ -2,7 +2,13 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { type ClassInfo } from "../lower-classes.js"; -import { DATE_METHOD_HINT, dateJsonType, dateValueMethod } from "./dates.js"; +import { + DATE_METHOD_HINT, + dateJsonType, + dateSetterTime, + dateValueMethod, + isDateSetter, +} from "./dates.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import { BOOL, @@ -248,6 +254,22 @@ export function lowerInheritedDateMethodCall( type: result.type, loc, }); + if (isDateSetter(name) && name !== "setTime") { + // The field setters change the Date the class's time slot holds once + // the arguments are evaluated: an argument that calls setTime on the + // instance gives the slot a new Date. + const date = lowerer.declareHiddenLocal("%dateTime", DATE_T); + const current = varRef(date.id, DATE_T, loc); + const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [current], type: F64, loc }; + const next = dateSetterTime(lowerer, call, name, stmts, time); + stmts.push({ + kind: "varDecl", + localId: date.id, + init: timeOf(lowerer, value, root, loc), + loc, + }); + return done({ kind: "libCall", fn: "date.store", args: [current, next], type: F64, loc }); + } if (name === "setTime") { // Every argument is evaluated before setTime converts the first one. let ms: IrExpr = { kind: "numLit", value: Number.NaN, type: F64, loc }; diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts index 0204386ab..075047606 100644 --- a/packages/compiler/src/frontend/lowering/builtins/dates.ts +++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts @@ -2,12 +2,15 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer, own } from "../lowerer.js"; import { locOf } from "../../program.js"; import { tryLowerExpression } from "../expressions/try-lower-expression.js"; +import { lowerToNumberArguments } from "../lower-exprs.js"; import { programDeclaresDateClass } from "./date-classes.js"; import { BOOL, + DATE_T, F64, type IrExpr, type IrLibFn, + type IrLocal, type IrStmt, type IrType, NULL_T, @@ -36,6 +39,32 @@ const DATE_GETTER_FNS: Readonly> = { getTimezoneOffset: "date.getTimezoneOffset", }; +/** The setters that change calendar fields: the field code + * scr_date_set_fields takes (scr_number.h SCR_DATE_SET_*; 8 marks the UTC + * forms) and the most arguments each takes. */ +const DATE_FIELD_SETTERS: ReadonlyMap = new Map([ + ["setMilliseconds", { code: 0, max: 1 }], + ["setSeconds", { code: 1, max: 2 }], + ["setMinutes", { code: 2, max: 3 }], + ["setHours", { code: 3, max: 4 }], + ["setDate", { code: 4, max: 1 }], + ["setMonth", { code: 5, max: 2 }], + ["setFullYear", { code: 6, max: 3 }], + ["setUTCMilliseconds", { code: 8, max: 1 }], + ["setUTCSeconds", { code: 9, max: 2 }], + ["setUTCMinutes", { code: 10, max: 3 }], + ["setUTCHours", { code: 11, max: 4 }], + ["setUTCDate", { code: 12, max: 1 }], + ["setUTCMonth", { code: 13, max: 2 }], + ["setUTCFullYear", { code: 14, max: 3 }], +]); + +/** Whether `name` is a Date setter scriptc lowers: setTime or a field + * setter. */ +export function isDateSetter(name: string): boolean { + return name === "setTime" || DATE_FIELD_SETTERS.has(name); +} + const DATE_TEXT_FNS: ReadonlyMap = new Map([ ["toString", "date.toString"], ["toDateString", "date.toDateString"], @@ -43,11 +72,11 @@ const DATE_TEXT_FNS: ReadonlyMap = new Map([ ]); export const DATE_METHOD_HINT = - "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering"; + "getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering"; -/** The Date slice: statics plus read-only Date values backed by one - * TimeClip'd millisecond scalar. Construction lives in lowerNew; - * identity and mutating methods remain fenced. */ +/** Date statics and methods on Date objects (a TimeClip'd millisecond + * time value with identity): getters, text forms and setters. + * Construction lives in lowerNew. */ export function lowerDateCall( lowerer: Lowerer, call: ts.CallExpression, @@ -67,16 +96,18 @@ export function lowerDateCall( if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; - // A Date a JavaScript file constructs is a mutable object; setTime on - // it takes the dynamic method call. So does every Date method on a + // A Date a JavaScript file constructs is a checked-dynamic value; a + // setter on it takes the dynamic method call, which changes the Date the + // handle holds. So does every Date method on a // checked-dynamic receiver when the program declares a class that extends // Date: the value can be an instance, whose class may declare the method. if ( - (name === "setTime" || programDeclaresDateClass(lowerer)) && + (isDateSetter(name) || programDeclaresDateClass(lowerer)) && tryLowerExpression(lowerer, access.expression)?.type.kind === "dyn" ) return null; if (name === "toJSON") return lowerDateToJson(lowerer, call, access); + if (isDateSetter(name)) return lowerDateSetter(lowerer, call, access, name); if (call.arguments.nodes.length !== 0) { lowerer.noLowering(`Date.prototype.${name} with arguments`, call, DATE_METHOD_HINT); } @@ -253,3 +284,151 @@ export function isDateJsonSource(lowerer: Lowerer, node: ts.Node): boolean { lowerer.isStdlibMember(access) ); } + +/** `d.setTime(t)` and the field setters (`d.setHours(h, m?, s?, ms?)` ...) + * on a Date object: the receiver, then the arguments in order, then the + * new time value (scr_date_set_fields over the time value the Date holds + * once the arguments have run) stored into the Date and answered. Missing + * optional arguments keep their fields; arguments past a setter's last + * one are evaluated and ignored. A Date local that holds its time value + * (date-values.ts) takes the new time value itself. */ +function lowerDateSetter( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + name: string, +): IrExpr { + const loc = locOf(call); + const nodes = call.arguments.nodes; + if (nodes.some((node) => ts.isSpreadElement(node))) + lowerer.noLowering(`Date.prototype.${name} with spread arguments`, call, DATE_METHOD_HINT); + const timeLocal = timeValueLocalOf(lowerer, access.expression); + const stmts: IrStmt[] = []; + let receiver: IrExpr | null = null; + if (timeLocal === null) { + // A checked-dynamic receiver takes the dynamic method call + // (lowerDateCall), so the value here is a Date object. + const value = lowerer.lowerExpr(access.expression); + if (value.type.kind !== "date") + lowerer.badType(access.expression, lowerer.typeOf(access.expression)); + const date = lowerer.declareHiddenLocal("%date", DATE_T); + stmts.push({ kind: "varDecl", localId: date.id, init: value, loc }); + receiver = { kind: "varRef", localId: date.id, type: DATE_T, loc }; + } + const time: IrExpr = + receiver === null + ? { kind: "varRef", localId: timeLocal!.id, type: F64, loc } + : { kind: "libCall", fn: "date.getTime", args: [receiver], type: F64, loc }; + const next = dateSetterTime(lowerer, call, name, stmts, time); + if (receiver === null) { + stmts.push({ kind: "assign", localId: timeLocal!.id, value: next, loc }); + return { + kind: "seqExpr", + stmts, + result: { kind: "varRef", localId: timeLocal!.id, type: F64, loc }, + type: F64, + loc, + }; + } + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "date.store", args: [receiver, next], type: F64, loc }, + type: F64, + loc, + }; +} + +/** The new time value of setter `name` called with `call`'s arguments on a + * Date whose time value `time` reads: the arguments evaluate into + * temporaries appended to `stmts` (ToNumber of each, an undefined one is + * NaN), and the result reads `time` after them. A missing first argument + * is NaN; missing optional arguments keep their fields; arguments past a + * setter's last one are evaluated and ignored. */ +export function dateSetterTime( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + stmts: IrStmt[], + time: IrExpr, +): IrExpr { + const loc = locOf(call); + const converted = lowerToNumberArguments( + lowerer, + call.arguments.nodes as readonly ts.Expression[], + ); + stmts.push(...converted.stmts); + const args: IrExpr[] = converted.values.map((value) => { + if (value.kind === "numLit") return value; + const arg = lowerer.declareHiddenLocal("%dateArg", F64); + stmts.push({ kind: "varDecl", localId: arg.id, init: value, loc }); + return { kind: "varRef", localId: arg.id, type: F64, loc }; + }); + const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + if (name === "setTime") + return { + kind: "libCall", + fn: "date.timeClip", + args: [args[0] ?? number(Number.NaN)], + type: F64, + loc, + }; + const setter = DATE_FIELD_SETTERS.get(name)!; + // The first argument is required: a missing one is undefined, so NaN. + const used = args.length === 0 ? [number(Number.NaN)] : args.slice(0, setter.max); + const operands = [...used]; + while (operands.length < 4) operands.push(number(0)); + return { + kind: "libCall", + fn: "date.setFields", + args: [time, number(setter.code), number(used.length), ...operands], + type: F64, + loc, + }; +} + +/** The local a Date binding that holds its time value lowers to, for an + * identifier `node` that names one (date-values.ts); null otherwise. */ +function timeValueLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (!ts.isIdentifier(node)) return null; + const local = lowerer.resolveLocal(node); + return local !== undefined && local !== null && lowerer.timeValueDateLocals.has(local) + ? local + : null; +} + +/** `d[Symbol.toPrimitive](hint)` on a Date with a literal hint: "number" + * answers the time value, "string" and "default" the toString() text (a + * Date's default hint is a string). Null for other calls. */ +export function lowerDateToPrimitiveCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.ElementAccessExpression, +): IrExpr | null { + const key = access.argumentExpression; + if ( + !ts.isPropertyAccessExpression(key) || + key.name.text !== "toPrimitive" || + !ts.isIdentifier(key.expression) || + !lowerer.isStdlibGlobal(key.expression, "Symbol") || + lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date" + ) + return null; + const loc = locOf(call); + const hint = call.arguments.nodes.length === 1 ? call.arguments.nodes[0]! : null; + const text = hint !== null && ts.isStringLiteralLike(hint) ? hint.text : null; + if (text !== "number" && text !== "string" && text !== "default") + return lowerer.noLowering( + 'Date.prototype[Symbol.toPrimitive] without a "number", "string" or "default" literal hint', + call, + ); + const raw = lowerer.lowerExpr(access.expression); + const date: IrExpr = + raw.type.kind === "dyn" + ? { kind: "libCall", fn: "date.checkedValue", args: [raw], type: DATE_T, loc } + : raw; + return text === "number" + ? { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc } + : { kind: "libCall", fn: "date.toString", args: [date], type: STRING, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index fc6d0a922..db1ed1c8c 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -54,6 +54,7 @@ import { import { classPrototypeData, hasClassPrototypeData, + prototypeReflectedFlag, observedPrototypeMethod, reflectClassPrototype, } from "./class-prototypes.js"; @@ -4179,6 +4180,103 @@ export class ClassDynamicDispatch { return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc }; } + /** `%dyn.class.bagHasOwn`: whether an instance's property bag holds `key` + * as its own property. False before the instance has a bag. */ + private readBagHasOwn(lowerer: Lowerer, bag: IrExpr, key: IrExpr, loc: SrcLoc): IrExpr { + const name = "%dyn.class.bagHasOwn"; + let helper = lowerer.liftedFnNamed(name); + if (!helper) { + const value = varRef("p.0", DYN, loc); + helper = { + name, + params: [ + { localId: "p.0", name: "bag", type: DYN }, + { localId: "p.1", name: "key", type: STRING }, + ], + returnType: BOOL, + locals: [ + { id: "p.0", name: "bag", type: DYN, mutable: false }, + { id: "p.1", name: "key", type: STRING, mutable: false }, + ], + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "undefined", value, type: BOOL, loc }, + then: [ + { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, + ], + else_: null, + loc, + }, + { + kind: "return", + value: { + kind: "libCall", + fn: "dyn.hasOwn", + args: [value, varRef("p.1", STRING, loc)], + type: BOOL, + loc, + }, + loc, + }, + ], + loc, + }; + lowerer.liftedFns.push(helper); + } + this.generated.add(helper); + return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc }; + } + + /** The `constructor` an instance of `info` inherits when it has no own + * one. The class's prototype view answers its own `constructor` (a + * replacement, or the class once reflection installed it). A view + * without one answers the class itself unless reflection had populated it + * (prototypeReflectedFlag, set when the view initializes): then the + * program deleted the property, and the read continues to the base + * class's prototype and past the last one to Object.prototype, as in + * Node. A prototype's own `constructor` is read with the instance + * `receiver` as `this`, so an accessor's getter runs on the instance. */ + private inheritedConstructor( + lowerer: Lowerer, + info: ClassInfo, + key: IrExpr, + receiver: IrExpr, + loc: SrcLoc, + ): IrExpr { + const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl!), DYN); + const prototype = hasClassPrototypeData(info) ? classPrototypeData(lowerer, info, loc) : null; + if (!prototype) return cls; + const base = info.base; + const read = (): IrExpr => ({ + kind: "libCall", + fn: "dyn.reflectGet", + args: [prototype, lowerer.coerceToExpected(key, DYN), receiver], + type: DYN, + loc, + }); + // Past the last class prototype, the read continues to Object.prototype's data property. + const next: IrExpr = + base && !base.builtinError && base.decl && !base.generic && !base.localClass + ? this.inheritedConstructor(lowerer, base, key, receiver, loc) + : { kind: "dynKeyGet", value: prototype, key, type: DYN, loc }; + return { + kind: "ternary", + cond: { kind: "libCall", fn: "dyn.hasOwn", args: [prototype, key], type: BOOL, loc }, + then: read(), + else_: { + kind: "ternary", + cond: varRef(prototypeReflectedFlag(lowerer, info), BOOL, loc), + then: next, + else_: cls, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + } + /** The hints for which some boxed class declares a conversion method. A * string conversion calls toString first, so without a declared toString * or Symbol.toPrimitive it answers Object.prototype.toString's @@ -5937,15 +6035,19 @@ export class ClassDynamicDispatch { !info.classDecorators ) { this.constructors.set(info.def.name, info); - const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN); const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + // `constructor` is an own property of every class prototype, so an + // ancestor's prototype answers for an instance of this class only + // after the program deletes the class's own: the instance's own + // property answers, then the class's prototype (inheritedConstructor). + const answered = this.readBagHasOwn(lowerer, bag, key, loc); return [ { kind: "return", value: { kind: "ternary", - cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc), + cond: answered, then: { kind: "libCall", fn: "dyn.bagGet", @@ -5953,7 +6055,13 @@ export class ClassDynamicDispatch { type: DYN, loc, }, - else_: cls, + else_: this.inheritedConstructor( + lowerer, + info, + key, + lowerer.coerceToExpected(receiver, DYN), + loc, + ), type: DYN, loc, }, diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index cb2ab2280..d005e64d1 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -13,7 +13,7 @@ import { type IrStmt, type SrcLoc, } from "../../ir/ir.js"; -import { locOf } from "../program.js"; +import { isJsSourceFile, locOf } from "../program.js"; import { classMemberNameOf, classValueRef, @@ -299,6 +299,23 @@ function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean { ); } +/** The global that is true once the class's prototype view has been + * populated by reflection (reflectClassPrototype): the view then holds an + * own `constructor` unless the program deleted it. A view that was never + * reflected leaves `constructor` implicit. */ +export function prototypeReflectedFlag(lowerer: Lowerer, info: ClassInfo): string { + if (info.prototypeReflectedFlag) return info.prototypeReflectedFlag; + const id = `%g.%prototype.data.${info.def.name}.reflected`; + lowerer.globalsList.push({ + id, + name: `${info.def.jsName ?? info.def.name}.prototype.reflected`, + type: BOOL, + mutable: true, + }); + info.prototypeReflectedFlag = id; + return id; +} + /** Populate the shared prototype view before exposing it to reflection. * Methods and accessors are non-enumerable and retain declaration identity. */ export function reflectClassPrototype( @@ -399,6 +416,12 @@ export function reflectClassPrototype( { key: "value", value: classValueRef(lowerer, info, info.decl) }, writable, ]), + { + kind: "assign", + localId: prototypeReflectedFlag(lowerer, info), + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + }, ); const context = newFnCtx(false, null, null, DYN); lowerer.fnStack.push(context); @@ -503,6 +526,69 @@ export function isErrorPrototypeValue( ); } +/** Whether a bare `C.prototype` read becomes the prototype of another object + * or is only compared or inspected: the argument of `Object.create` or + * `Object.getPrototypeOf` / `Reflect.getPrototypeOf`, the prototype argument + * of `Object.setPrototypeOf` / `Reflect.setPrototypeOf`, or an operand of + * `===` / `!==`. In these positions the complete view of the + * prototype (reflectClassPrototype) answers. Objects that inherit from it + * run the class's methods with themselves as the receiver, which the + * methods' receiver-independent lowering (reflectedClassMethodValue) + * supports. Other positions, such as `Object.setPrototypeOf(C.prototype, X)`, + * which would relink the class, keep the diagnostic. */ +function prototypeViewPosition(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + let node: ts.Node = expr; + let parent = node.parent; + while (parent && ts.isParenthesizedExpression(parent)) { + node = parent; + parent = parent.parent; + } + if (!parent) return false; + if (ts.isCallExpression(parent) && ts.isPropertyAccessExpression(parent.expression)) { + const args = parent.arguments.nodes; + const objectMethod = lowerer.stdlibGlobalMember(parent.expression, "Object"); + const reflectMethod = lowerer.stdlibGlobalMember(parent.expression, "Reflect"); + if ( + objectMethod === "create" || + objectMethod === "getPrototypeOf" || + reflectMethod === "getPrototypeOf" + ) + return args[0] === node; + if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf") + return args[1] === node; + return false; + } + return ( + ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken) + ); +} + +/** A module-level JavaScript class named directly (not a class value that + * may hold a subclass at run time): its prototype has a complete view whose + * methods accept any receiver. */ +function viewableClass(lowerer: Lowerer, info: ClassInfo, receiver: ts.Expression): boolean { + return ( + info.decl !== null && + info.localClass === undefined && + isJsSourceFile(ts.getSourceFileOfNode(info.decl)) && + exactClassOfReceiver(lowerer, receiver) === info + ); +} + +/** The complete prototype view of the class `node` names, when it is a + * viewable class (util.inherits's superCtor); null otherwise. */ +export function classPrototypeView( + lowerer: Lowerer, + node: ts.Expression, + loc: SrcLoc, +): IrExpr | null { + const info = exactClassOfReceiver(lowerer, node); + if (!info || !viewableClass(lowerer, info, node)) return null; + return reflectClassPrototype(lowerer, info, loc, classValueRef(lowerer, info, node)); +} + /** Named data and method slots have native descriptors. Bare reflection and * accessor replacement still require a complete view of the prototype. */ export function lowerClassPrototypeData( @@ -566,6 +652,12 @@ export function lowerClassPrototypeData( }); return reflectClassPrototype(lowerer, info, locOf(expr), receiver); } + if ( + name === null && + prototypeViewPosition(lowerer, expr) && + viewableClass(lowerer, info, expr.expression) + ) + return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr)); if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/date-values.ts b/packages/compiler/src/frontend/lowering/date-values.ts index ef5eafebe..8931d0f80 100644 --- a/packages/compiler/src/frontend/lowering/date-values.ts +++ b/packages/compiler/src/frontend/lowering/date-values.ts @@ -1,5 +1,6 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; +import { isDateSetter } from "./builtins/dates.js"; /** Date methods whose receiver is only read for its time value: a Date * local used as their receiver can store the time value instead of a Date @@ -41,12 +42,15 @@ const RELATIONAL = new Set([ /** True when a local `const` (or a `let` that is never assigned again) * initialized with `new Date(...)` can keep the Date's time value instead - * of a Date object: no use observes the Date's identity. Every use reads the time value as - * a Date method receiver, a String() or template operand, a relational or - * string `+` operand, or `new Date(d)`'s argument, inside the declaring - * function, or inside the block or loop that scopes a module-level - * binding (no closure captures it). Top-level module bindings keep the - * object. Other uses box the time value into a new Date at the use. */ + * of a Date object: no use observes the Date's identity. Every use reads + * the time value as a Date method receiver (a setter replaces it; the + * arguments of a method that reads it call no setter on it), a + * String(), Number() or template operand, a unary + or - operand, a + * relational or string `+` operand, or `new Date(d)`'s argument, inside + * the declaring function, or inside the block or loop that scopes a + * module-level binding (no closure captures it). Top-level module bindings + * keep the object. Other uses box the time value into a new Date at the + * use. */ export function isTimeValueDateDeclaration( lowerer: Lowerer, decl: ts.VariableDeclaration, @@ -94,17 +98,21 @@ function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): bo const symbol = lowerer.checker.getSymbolAtLocation(decl.name); if (!symbol) return false; let ok = true; + let sets = false; const visit = (node: ts.Node): void => { if (!ok) return; if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) { if (lowerer.checker.getSymbolAtLocation(node) === symbol) { - if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false; + const use = enclosingFunction(node) === fn ? timeValueUse(lowerer, node, symbol) : null; + if (use === null) ok = false; + else if (use === "set") sets = true; } return; } ts.forEachChild(node, visit); }; visit(scope); + if (ok && sets) lowerer.timeValueDateWrites.add(decl); return ok; } @@ -164,29 +172,75 @@ function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { return null; } -/** A use of the binding that reads only its time value. */ -function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean { +/** How a use of the binding uses its time value: "read" for a use that + * reads only the time value, "set" for a setter call that replaces it, or + * null for any other use. */ +function timeValueUse( + lowerer: Lowerer, + id: ts.Identifier, + symbol: ts.Symbol, +): "read" | "set" | null { let node: ts.Node = id; while (ts.isParenthesizedExpression(node.parent)) node = node.parent; const parent = node.parent; + if (parent === undefined) return null; if (ts.isPropertyAccessExpression(parent) && parent.expression === node) { const call = parent.parent; - return ( - !parent.questionDotToken && - ts.isCallExpression(call) && - call.expression === parent && - !call.questionDotToken && - TIME_VALUE_METHODS.has(parent.name.text) && - lowerer.isStdlibMember(parent) - ); + if ( + parent.questionDotToken || + !ts.isCallExpression(call) || + call.expression !== parent || + call.questionDotToken || + !lowerer.isStdlibMember(parent) + ) + return null; + if (isDateSetter(parent.name.text)) return "set"; + // The method reads the time value after its arguments are evaluated: + // a setter there (`d.toJSON(d.setTime(0))`) changes what it reads. + if (call.arguments.nodes.some((arg) => callsSetterOf(lowerer, arg, symbol))) return null; + return TIME_VALUE_METHODS.has(parent.name.text) ? "read" : null; } + return readsTimeValue(lowerer, node, parent, symbol) ? "read" : null; +} + +/** True when `node` calls a setter on the binding `symbol` names. */ +function callsSetterOf(lowerer: Lowerer, node: ts.Node, symbol: ts.Symbol): boolean { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + isDateSetter(node.expression.name.text) + ) { + let receiver = node.expression.expression; + while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + if (ts.isIdentifier(receiver) && lowerer.checker.getSymbolAtLocation(receiver) === symbol) + return true; + } + return ( + ts.forEachChild(node, (child) => callsSetterOf(lowerer, child, symbol) || undefined) ?? false + ); +} + +/** A use of the binding, other than a method call, that reads only its + * time value. */ +function readsTimeValue( + lowerer: Lowerer, + node: ts.Node, + parent: ts.Node, + symbol: ts.Symbol, +): boolean { if (ts.isTemplateSpan(parent)) return parent.expression === node; + // Unary + and -, the number hint. + if (ts.isPrefixUnaryExpression(parent)) + return ( + parent.operator === ts.SyntaxKind.PlusToken || parent.operator === ts.SyntaxKind.MinusToken + ); if (ts.isCallExpression(parent)) { return ( parent.arguments.nodes.length === 1 && parent.arguments.nodes[0] === node && ts.isIdentifier(parent.expression) && - lowerer.isStdlibGlobal(parent.expression, "String") + (lowerer.isStdlibGlobal(parent.expression, "String") || + lowerer.isStdlibGlobal(parent.expression, "Number")) ); } if (ts.isNewExpression(parent)) { @@ -198,6 +252,9 @@ function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean { ); } if (ts.isBinaryExpression(parent)) { + // A left operand converts after the right one is evaluated: a setter + // there changes the time value the comparison or concatenation reads. + if (parent.left === node && callsSetterOf(lowerer, parent.right, symbol)) return false; const other = parent.left === node ? parent.right : parent.left; const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind; if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date"; diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index dbe89544e..4de307be1 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1606,6 +1606,19 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; } + // A Date against an untyped value: the same Date object, whose + // checked-dynamic handle the untyped value would hold. + if (dynSide.type.kind === "dyn" && scalarSide.type.kind === "date") { + const boxed = lowerer.coerceToExpected(scalarSide, DYN); + return { + kind: "dynScalarEq", + left: scalarSide === left ? boxed : left, + right: scalarSide === right ? boxed : right, + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } if ( dynSide.type.kind === "dyn" && (scalarSide.type.kind === "f64" || @@ -2030,6 +2043,28 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; if (left.type.kind === "string" || right.type.kind === "string") { + // A Date on the left converts after the right operand is + // evaluated, which can run its setters. + if (left.type.kind === "date" && !isSafeToRepeat(right)) { + const date = lowerer.declareHiddenLocal("%plus", left.type); + const other = lowerer.declareHiddenLocal("%plus", right.type); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: date.id, init: left, loc }, + { kind: "varDecl", localId: other.id, init: right, loc }, + ], + result: { + kind: "strConcat", + left: concatOperand(lowerer, varRef(date.id, left.type, loc), expr.left), + right: concatOperand(lowerer, varRef(other.id, right.type, loc), expr.right), + type: STRING, + loc, + }, + type: STRING, + loc, + }; + } return { kind: "strConcat", left: concatOperand(lowerer, left, expr.left), diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 08c472374..8c06b0272 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -117,6 +117,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../. import { typedArrayLacksMember } from "../builtins/typed-array-members.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { DATE_METHOD_HINT } from "../builtins/dates.js"; import { cjsClassExprWholeExportOf, cjsExportAssignmentOf, @@ -2062,6 +2063,9 @@ export function lowerIntrinsicProperty( ); } if (kind === "date") { + // A Date held as a checked-dynamic value (a Date a JavaScript file + // constructs) answers its method values at run time. + if (tryLowerExpression(lowerer, expr.expression)?.type.kind === "dyn") return null; const methods = new Set([ "getTime", "valueOf", @@ -2090,7 +2094,7 @@ export function lowerIntrinsicProperty( lowerer.noLowering( `Date.prototype.${name}`, expr, - "getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering", + DATE_METHOD_HINT, lowerer.checker.getSymbolAtLocation(expr.name), ); } diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 08987e538..0d6b949cb 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -97,6 +97,8 @@ const NO_CLASS_INSTANCE_KINDS: ReadonlySet = new Set([ "nullT", ]); +const PRIMITIVE_WRAPPERS = ["Boolean", "Number", "String", "BigInt", "Symbol"] as const; + /** `x instanceof C` for a program-declared class C. When x's static * class and C are both in extends-hierarchies the test is dynamic — the * O(1) preorder-interval check against the vtable (`instanceOf` node). @@ -190,6 +192,20 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc loc, }; } + // The primitive wrapper constructors: a compiled program never creates a + // boxed primitive (`new Boolean(...)` and friends have no lowering), and + // a primitive is never an instance, so the test is false for every value + // (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs. + if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) { + const value = lowerer.lowerExpr(expr.left); + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: value, loc }], + result: { kind: "boolLit", value: false, type: BOOL, loc }, + type: BOOL, + loc, + }; + } if (lowerer.isStdlibGlobal(expr.right, "FinalizationRegistry")) { const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); return { kind: "libCall", fn: "finalizationRegistry.is", args: [value], type: BOOL, loc }; @@ -291,6 +307,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc if (!target) { const date = lowerer.isStdlibGlobal(expr.right, "Date"); const url = lowerer.isStdlibGlobal(expr.right, "URL"); + // A catch binding holds a thrown Date as its checked-dynamic handle. + const caughtDate = date ? lowerer.caughtLocalOf(expr.left) : null; + if (caughtDate) + return { + kind: "libCall", + fn: "dyn.nativeDateIs", + args: [{ kind: "caughtToDyn", value: varRef(caughtDate.id, CAUGHT, loc), type: DYN, loc }], + type: BOOL, + loc, + }; if ((date || url) && !lowerer.caughtLocalOf(expr.left)) { const value = lowerer.lowerExpr(expr.left); if (value.type.kind === "dyn") @@ -1905,6 +1931,30 @@ export function lowerInExpression( }; } } + // A child process has `send`, `disconnect` and `channel` only when it was + // created with an IPC channel (fork, or an "ipc" stdio entry), and keeps + // them after the channel closes: the runtime answers from the child. + if (recv.type.kind === "child" && CHILD_CHANNEL_MEMBERS.has(key)) + return { kind: "libCall", fn: "child.hasChannel", args: [recv], type: BOOL, loc }; + // A native Node handle (an http.Server, a socket, a child process...): + // a member Node's declarations give the class is on its prototype, so + // `"closeAllConnections" in server` answers true. Undeclared names can + // still be real (internal `_`-prefixed fields), so they keep the fence. + if ( + NATIVE_HANDLE_KINDS.has(recv.type.kind) && + nodeDeclaredMember(lowerer, lowerer.typeOf(expr.right), key) + ) { + const answer: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + return isSafeToDiscard(recv) + ? answer + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: recv, loc }], + result: answer, + type: BOOL, + loc, + }; + } if (recv.type.kind !== "record") { lowerer.unsupported( "SC1090", @@ -2181,24 +2231,43 @@ function lowerRuntimeKeyIn( return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; } -/** Standard-library constructors the untyped instanceof helper has no - * instance test for: an untyped `x instanceof Object` always refuses. */ -const DYN_INSTANCEOF_REFUSED: ReadonlySet = new Set(["Object", "Function"]); - /** The SC1090 message `x instanceof C` throws, with the site's position, - * when `C` is a runtime constructor with no instance test. A right-hand - * side that names one of DYN_INSTANCEOF_REFUSED always reaches that - * refusal, so the site is listed with the runtime fences. */ + * when `C` is a runtime constructor with no instance test. */ function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): string { const diag = unsupportedDiag("SC1090", locOf(expr), "instanceof against this native constructor"); - const symbol = lowerer.checker.getSymbolAtLocation(expr.right); - if ( - symbol !== undefined && - lowerer.isStdlibSymbol(symbol) && - DYN_INSTANCEOF_REFUSED.has(ts.symbolName(symbol)) - ) - lowerer.runtimeFences.push(diag); const sf = ts.getSourceFileOfNode(expr); const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1; return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`; } + +/** IR kinds of native Node handles whose members come from Node's own + * declarations. */ +const NATIVE_HANDLE_KINDS: ReadonlySet = new Set([ + "netServer", + "netSocket", + "httpReq", + "httpRes", + "httpClientReq", + "child", + "dgramSocket", + "fsWatcher", + "http2Session", + "http2Stream", +]); + +/** Child process members that exist only on a child with an IPC channel. */ +const CHILD_CHANNEL_MEMBERS: ReadonlySet = new Set(["send", "disconnect", "channel"]); + +/** Whether every constituent of `t` declares a required member `key` in + * a declaration file (Node's @types/node). */ +function nodeDeclaredMember(lowerer: Lowerer, t: ts.Type, key: string): boolean { + const parts = t.isUnionType() ? ts.constituentTypes(t) : [t]; + return parts.every((part) => { + const prop = lowerer.checker.getPropertyOfType(part, key); + if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) !== 0) return false; + return ( + prop.declarations.length > 0 && + prop.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile) + ); + }); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index fbbc068b9..f6743768a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -35,6 +35,7 @@ import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors import { InternalCompilerError } from "../../../errors.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { isTemporalGlobalRoot } from "../lower-namespaces.js"; +import { isF64HandleTyped } from "../f64-handles.js"; export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr { const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression); @@ -281,13 +282,18 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc promise: "object", bytes: "object", regex: "object", + date: "object", generator: "object", classval: "function", moduleNs: "object", undefinedT: "undefined", nullT: "object", }; - const folded = FOLD[operand.type.kind]; + // An f64 handle (a readline Interface, a Timeout) is an object. + const folded = + operand.type.kind === "f64" && isF64HandleTyped(lowerer, expr.expression) + ? "object" + : FOLD[operand.type.kind]; if (folded) { const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc }; return isSafeToDiscard(operand) diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index bdcd94a44..35d5d1a2e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -120,6 +120,11 @@ function coercedUnion(lowerer: Lowerer, type: IrType): boolean { return arms.some((arm) => arm.kind !== "f64" && !isUnitType(arm)); } +/** ToNumber of a Date: its time value (valueOf, the number hint). */ +function dateTimeValue(date: IrExpr, loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc }; +} + /** ToNumber of a boolean: 1 for true, 0 for false. */ function booleanToNumber(value: IrExpr, loc: SrcLoc): IrExpr { return { @@ -145,23 +150,25 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio const operand = raw.type.kind === "bool" ? booleanToNumber(raw, loc) - : coercedUnion(lowerer, raw.type) - ? ({ - kind: "libCall", - fn: "dyn.toNumberCoerce", - args: [{ kind: "dynFrom", value: raw, type: DYN, loc }], - type: F64, - loc, - } satisfies IrExpr) - : raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) + : raw.type.kind === "date" + ? dateTimeValue(raw, loc) + : coercedUnion(lowerer, raw.type) ? ({ kind: "libCall", fn: "dyn.toNumberCoerce", - args: [raw], + args: [{ kind: "dynFrom", value: raw, type: DYN, loc }], type: F64, loc, } satisfies IrExpr) - : lowerOptionalNumber(lowerer, raw, loc, expr.operand); + : raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) + ? ({ + kind: "libCall", + fn: "dyn.toNumberCoerce", + args: [raw], + type: F64, + loc, + } satisfies IrExpr) + : lowerOptionalNumber(lowerer, raw, loc, expr.operand); if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]); if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc }; return { kind: "unary", op: "-", operand, type: F64, loc }; @@ -185,6 +192,8 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc }; } if (raw.type.kind === "bool") return booleanToNumber(raw, loc); + // A Date's number hint answers its time value. + if (raw.type.kind === "date") return dateTimeValue(raw, loc); const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); if (classNumber) return classNumber; const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); diff --git a/packages/compiler/src/frontend/lowering/f64-handles.ts b/packages/compiler/src/frontend/lowering/f64-handles.ts new file mode 100644 index 000000000..0d7da0ff9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/f64-handles.ts @@ -0,0 +1,15 @@ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Whether `node`'s value is an f64 handle: an object whose class + * type-mapper.ts represents as an f64 id into a runtime registry (a + * readline Interface, a Timeout or Immediate, an AsyncLocalStorage, a + * diagnostics_channel Channel). Its checker type is an object type that + * maps to f64; the Number wrapper interface maps to f64 for primitives. */ +export function isF64HandleTyped(lowerer: Lowerer, node: ts.Node): boolean { + const type = lowerer.checker.getTypeAtLocation(node); + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + const symbol = type.getSymbol(); + if (symbol !== undefined && ts.symbolName(symbol) === "Number") return false; + return lowerer.mapTypeOf(type)?.kind === "f64"; +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index b996abed7..02faa7ade 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -51,11 +51,15 @@ import { STRING, type SrcLoc, VOID, + isDynTypedRefType, isUnitType, typeEquals, typeKey, } from "../../ir/ir.js"; import { boolLit, countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js"; +import { everyExpr } from "../../ir/traverse.js"; +import { checkedClassInstanceOf } from "./class-construction.js"; +import { isJsSourceFile } from "../program.js"; /** node:assert/strict binds the loose NAMES to the strict comparisons — * Node's own aliasing (`strict.equal === assert.strictEqual`). */ @@ -73,6 +77,63 @@ function canonicalAssertMember(module: string, member: string): string { return member; } +/** The assert methods that are function values (scr_assert_method_value). */ +const ASSERT_FUNCTION_VALUES: ReadonlySet = new Set([ + "ok", + "fail", + "equal", + "notEqual", + "strictEqual", + "notStrictEqual", + "deepEqual", + "notDeepEqual", + "deepStrictEqual", + "notDeepStrictEqual", + "partialDeepStrictEqual", + "ifError", +]); + +/** The assert method a `const` binding holds (`const ok = assert.ok`, + * `const eq = strictEqual`), or null. */ +export function assertFunctionAliasOf( + lowerer: Lowerer, + ident: ts.Identifier, +): { module: string; member: string } | null { + const decl = lowerer.checker.getSymbolAtLocation(ident)?.valueDeclaration; + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & ts.NodeFlags.Const) === 0 || + !decl.initializer + ) + return null; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + const bi = ts.isPropertyAccessExpression(init) + ? lowerer.builtinMemberOf(init) + : ts.isIdentifier(init) + ? lowerer.builtinImportOf(init) + : null; + if (!bi || (bi.module !== "assert" && bi.module !== "assert/strict")) return null; + return ASSERT_FUNCTION_VALUES.has(canonicalAssertMember(bi.module, bi.member)) ? bi : null; +} + +/** An assert method read as a value (`emitter.on("x", assert.fail)`, + * `const eq = assert.strictEqual`): an untyped function that asserts as + * the method does, the same value for every read. Null for other + * members. */ +export function assertFunctionValue( + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { + if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + const member = canonicalAssertMember(bi.module, bi.member); + if (!ASSERT_FUNCTION_VALUES.has(member)) return null; + return { kind: "libCall", fn: "assert.methodValue", args: [strLit(member, loc)], type: DYN, loc }; +} + /* Node's AssertionError carries actual, expected and operator. Where the * verdict is computed in compiled code, the failure branch records them * (assert.record) before the throwing call, so a passing assertion boxes @@ -166,28 +227,156 @@ function requireStatementPosition(lowerer: Lowerer, call: ts.CallExpression, wha ); } +/** The message the runtime reads from a registered value + * (SCR_ASSERT_VALUE_MESSAGE in scr_assert.h). */ +const VALUE_MESSAGE = "\u0001scriptc:assert-message-value"; + +/** A message argument that is not a string, met while one assertion + * lowers (withValueMessage). `fail` marks assert.fail's message, which + * Node uses for every value but null and undefined. */ +let valueMessage: { value: IrExpr; has: string; text: string; fail: boolean } | null = null; + /** The optional trailing message argument: Node accepts a string or an * Error (throwing the Error itself) — the STRING form lowers; a literal * `undefined` counts as omitted (Node's `message == null` test). Returns * the libCall's (msg, hasMsg) pair — a "" dummy when omitted, so the - * runtime can distinguish an omitted message from an empty one. */ + * runtime can distinguish an omitted message from an empty one. Any other + * value that crosses into the checked-dynamic tree (an untyped + * `failures.join('')`, an Error) is registered with the runtime at the call + * instead (withValueMessage). */ function lowerMessageArg( lowerer: Lowerer, node: ts.Expression | undefined, loc: SrcLoc, + fail = false, ): { msg: IrExpr; hasMsg: IrExpr } { if (!node || (ts.isIdentifier(node) && node.text === "undefined")) { return { msg: { kind: "strLit", value: "", type: STRING, loc }, hasMsg: boolLit(false, loc) }; } const msg = lowerer.lowerExpr(node); - if (msg.type.kind !== "string") { + if (msg.type.kind === "string") return { msg, hasMsg: boolLit(true, loc) }; + const value = + valueMessage === null && (msg.type.kind === "dyn" || lowerer.dynConvertible(msg.type)) + ? lowerer.coerceToExpected(msg, DYN) + : null; + if (value === null || value.type.kind !== "dyn") { lowerer.noLowering( `assert messages of type '${lowerer.fmt(msg.type)}'`, node, "only string messages lower (Node also accepts an Error to throw — construct and throw it directly)", ); } - return { msg, hasMsg: boolLit(true, loc) }; + const has = lowerer.declareHiddenLocal("assert.hasMessage", BOOL); + const text = lowerer.declareHiddenLocal("assert.messageText", STRING); + valueMessage = { value, has: has.id, text: text.id, fail }; + return { msg: varRef(text.id, STRING, loc), hasMsg: varRef(has.id, BOOL, loc) }; +} + +/** Lowers one assertion; a message value met on the way is evaluated after + * the assertion's other arguments, as Node evaluates them. A null or + * undefined message is omitted (Node's `message == null` test); a truthy + * one is registered with the runtime, which the failure reads; any other + * value is the empty message, which Node's comparison diffs treat as no + * message too (createErrDiff tests the message's truthiness). */ +function withValueMessage( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + lower: () => IrExpr | null, +): IrExpr | null { + const outer = valueMessage; + valueMessage = null; + try { + const lowered = lower(); + const message = valueMessage as { + value: IrExpr; + has: string; + text: string; + fail: boolean; + } | null; + if (lowered === null || message === null) return lowered; + // assert.ok's statements evaluate the value first; its call is last. + const before = lowered.kind === "seqExpr" ? lowered.stmts : []; + const call = lowered.kind === "seqExpr" ? lowered.result : lowered; + if (call.kind !== "libCall") { + lowerer.noLowering( + "assert messages that are not strings in this assertion form", + expr, + "pass a string message", + ); + } + const stmts: IrStmt[] = [...before]; + // An argument computed from the message (assert.fail's generated flag) + // reads it after the declarations below; the others are evaluated first. + const readsMessage = (arg: IrExpr): boolean => + !everyExpr(arg, { + stmt: () => true, + expr: (e) => + !(e.kind === "varRef" && (e.localId === message.has || e.localId === message.text)), + }); + const args = call.args.map((arg, index) => + readsMessage(arg) ? arg : stableValue(lowerer, stmts, arg, `assert.arg${index}`, loc), + ); + const local = lowerer.declareHiddenLocal("assert.message", DYN); + const value = varRef(local.id, DYN, loc); + // The runtime reports a registered value: a truthy one for the other + // methods, any value but null and undefined for assert.fail (Node's + // `actual == null` test), whose other values read "Failed". + const registered: IrExpr = message.fail + ? varRef(message.has, BOOL, loc) + : { kind: "dynTest", test: "truthy", value, type: BOOL, loc }; + stmts.push( + { kind: "varDecl", localId: local.id, init: message.value, loc }, + { + kind: "varDecl", + localId: message.has, + init: { kind: "dynTest", test: "nullish", negated: true, value, type: BOOL, loc }, + loc, + }, + { + kind: "varDecl", + localId: message.text, + init: { + kind: "ternary", + cond: registered, + then: strLit(VALUE_MESSAGE, loc), + else_: strLit(message.fail ? "Failed" : "", loc), + type: STRING, + loc, + }, + loc, + }, + { + kind: "if", + cond: registered, + then: [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "assert.messageValue", + args: [value, { kind: "dynTest", test: "error", value, type: BOOL, loc }], + type: VOID, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "exprStmt", expr: { ...call, args }, loc }, + ); + return { + kind: "seqExpr", + stmts, + result: { kind: "libCall", fn: "assert.messageClear", args: [], type: VOID, loc }, + type: VOID, + loc, + }; + } finally { + valueMessage = outer; + } } /** The module-function dispatch — the spoke's entry, called from both the @@ -201,6 +390,15 @@ export function lowerAssertModuleCall( loc: SrcLoc, ): IrExpr | null { if (bi.module !== "assert" && bi.module !== "assert/strict") return null; + return withValueMessage(lowerer, expr, loc, () => lowerAssertMember(lowerer, expr, bi, loc)); +} + +function lowerAssertMember( + lowerer: Lowerer, + expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc, +): IrExpr | null { switch (canonicalAssertMember(bi.module, bi.member)) { case "ok": return lowerAssertOk(lowerer, expr, loc); @@ -880,7 +1078,7 @@ export function lowerAssertDirectCall( 'use the default import: import assert from "node:assert")', ); } - return lowerAssertOk(lowerer, expr, loc); + return withValueMessage(lowerer, expr, loc, () => lowerAssertOk(lowerer, expr, loc)); } /** assert(value[, message]) / assert.ok(value[, message]): the truthiness @@ -889,7 +1087,24 @@ export function lowerAssertDirectCall( * the same bytes at compile time). */ function lowerAssertOk(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { requireStatementPosition(lowerer, expr, ts.getTextOfNode(expr.expression)); - if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) { + if (expr.arguments.nodes.length === 0) { + // Node's message for a call without arguments. + const text = "No value argument passed to `assert.ok()`"; + return { + kind: "seqExpr", + stmts: [recordOnFailure(lowerer, boolLit(true, loc), null, boolLit(true, loc), "==", loc)], + result: { + kind: "libCall", + fn: "assert.ok", + args: [boolLit(false, loc), strLit(text, loc), boolLit(true, loc)], + type: VOID, + loc, + }, + type: VOID, + loc, + }; + } + if (expr.arguments.nodes.length > 2) { lowerer.noLowering( `${ts.getTextOfNode(expr.expression)} with ${expr.arguments.nodes.length} arguments`, expr, @@ -953,22 +1168,26 @@ function lowerAssertFail(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc) generated = boolLit(true, loc); } else { // Node's AssertionError sets generatedMessage to !message, so an - // empty custom message counts as generated. - msg = stableValue( - lowerer, - stmts, - lowerMessageArg(lowerer, msgNode, loc).msg, - "%assertFailMessage", - loc, - ); - generated = { - kind: "strEq", - negated: false, - left: msg, - right: strLit("", loc), - type: BOOL, - loc, - }; + // empty custom message counts as generated. A message that is not a + // string is read from the hidden local that withValueMessage declares + // after these statements, so it is used as is. + const { msg: lowered, hasMsg } = lowerMessageArg(lowerer, msgNode, loc, true); + if (lowered.kind === "varRef" && hasMsg.kind === "varRef") { + // A value message: generated when Node reads "Failed" for null or + // undefined; the runtime sets the flag for a registered value. + msg = lowered; + generated = { kind: "unary", op: "!", operand: hasMsg, type: BOOL, loc }; + } else { + msg = stableValue(lowerer, stmts, lowered, "%assertFailMessage", loc); + generated = { + kind: "strEq", + negated: false, + left: msg, + right: strLit("", loc), + type: BOOL, + loc, + }; + } } const falseLit: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; stmts.push(recordOnFailure(lowerer, boolLit(true, loc), null, null, "fail", loc)); @@ -1056,9 +1275,22 @@ function lowerAssertEqual( // would answer false where Node can answer true (the value's own // earlier crossing) — a miscompare, never lowered. Bytes fence both // ways (the dyn copy cannot carry the Buffer/Uint8Array brand the - // checker type knows); class instances and everything non-convertible - // keep the fence too. - if (a.type.kind === "dyn" || b.type.kind === "dyn") { + // checker type knows); everything non-convertible keeps the fence too. + // A class instance crosses as a reference to the instance itself, so + // strict compares are honest reference equality, between two instances + // too (assert.strictEqual(e, e.setMaxListeners(1))). + const instanceType = (t: IrType): boolean => + isDynTypedRefType(t) && !lowerer.isSubclassOf(t.className, "%Error"); + const instance = (e: IrExpr): boolean => + !deep && + (instanceType(e.type) || + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? + false))) && + lowerer.dynConvertible(e.type); + if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; const k = e.type.kind; @@ -1067,6 +1299,18 @@ function lowerAssertEqual( k === "f64" || k === "string" || k === "bool" || + // A union of scalars and units crosses by value, like its arms. + (e.type.kind === "union" && + (lowerer.unions + .get(e.type.unionId) + ?.arms.every( + (arm) => + arm.kind === "f64" || + arm.kind === "string" || + arm.kind === "bool" || + isUnitType(arm), + ) ?? + false)) || (k === "func" && lowerer.dynConvertible(e.type)) || // Runtime handles cross by REFERENCE (identity preserved), so the // strict compare is honest: assert.strictEqual(this, server) — @@ -1082,6 +1326,9 @@ function lowerAssertEqual( // same). The deep walk compares the retained error data without // re-encoding and losing identity (SEMANTICS.md). (k === "object" && e.type.kind === "object" && e.type.className === "%Error") || + instance(e) || + // A symbol crosses as itself, so strict compares are identity. + (!deep && k === "symbol") || (deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) || // Under skipPrototype the prototype a class instance or Buffer // brand would carry is not compared. @@ -1470,17 +1717,29 @@ function classifyThrowsExpected( displayName: rec ? rec.lib : info.def.name.replace(/^%/, ""), }; } - if ( - (surface === "assert.doesNotThrow" || surface === "assert.doesNotReject") && - expectedT?.kind === "func" - ) { + if (expectedT?.kind === "func") { const value = lowerer.lowerExpr(node); + // An untyped value there (a dynamic-tier function's result) is checked + // at run time like any untyped expected value. + if (value.type.kind === "jsval" || value.type.kind === "dyn") { + const boxed = lowerer.coerceToExpected(value, DYN); + if (boxed.type.kind === "dyn") return { form: "errValue", value: boxed }; + } + // A JavaScript function accepts any argument; a typed one must take + // unknown values (the caught value need not be an Error). if ( value.type.kind === "func" && - value.type.params.every((param) => param.kind === "dyn") && + (value.type.params.every((param) => param.kind === "dyn") || + isJsSourceFile(ts.getSourceFileOfNode(node))) && lowerer.dynConvertible(value.type) ) { - return { form: "predicate", value: lowerer.coerceToExpected(value, DYN) }; + // The boxed function carries its source name (Node's message names + // a named validation function). + const fnName = jsFuncNameOf(node); + return { + form: "predicate", + value: { kind: "dynFrom", value, ...(fnName !== null ? { fnName } : {}), type: DYN, loc }, + }; } lowerer.noLowering( `${surface} with this validation callback`, @@ -1546,7 +1805,9 @@ function classifyThrowsExpected( // tracingChannel suite's shape): the value crosses as a dyn error and // the runtime compares per Node's expectsError key walk. A // checked-dynamic expected rides directly; a static %Error boxes. - if (expectedT?.kind === "dyn") { + // An untyped JavaScript value (a parameter) too: a function there is + // checked at run time (assertThrowsHelper). + if (expectedT?.kind === "dyn" || expectedT === null) { const value = lowerer.lowerExpr(node); if (value.type.kind === "dyn") return { form: "errValue", value }; } @@ -1943,6 +2204,46 @@ function assertThrowsHelper( return { e: strLit("", loc), has: false }; })(); + // expectedException's function branch: a function with a prototype that + // the caught value is an instance of passes; anything else is called as + // a validation function, whose result must be true. + const validateStmts = (fn: IrExpr): IrStmt[] => { + const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); + const prototype: IrExpr = { + kind: "dynKeyGet", + value: fn, + key: strLit("prototype", loc), + optional: true, + type: DYN, + loc, + }; + return [ + ifStmt( + { kind: "dynTest", test: "undefined", negated: true, value: prototype, type: BOOL, loc }, + [ + ifStmt( + checkedClassInstanceOf( + lowerer, + actual(), + fn, + "assert.throws against this native constructor is not supported yet", + loc, + ), + [doReturn], + ), + ], + ), + lib("assert.throwsValidate", [ + actual(), + fn, + strLit(mode === "rejects" ? "rejects" : "throws", loc), + m(), + hm(), + ]), + doReturn, + ]; + }; + let catchBody: IrStmt[]; if (mode === "dnr" || mode === "dnt") { const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }); @@ -2116,7 +2417,13 @@ function assertThrowsHelper( // caught value crosses as a dyn value (identity-preserving) and // the runtime walks the expected's keys — a mismatch throws the // deep-equal AssertionError. + // A function there is a validation function (or an error class + // value) as in the predicate form. catchBody = [ + ifStmt( + { kind: "dynTest", test: "function", value: varRef("ev.0", DYN, loc), type: BOOL, loc }, + validateStmts(varRef("ev.0", DYN, loc)), + ), lib("assert.expectsErrDyn", [ { kind: "caughtToDyn", value: caughtRef(), type: DYN, loc }, varRef("ev.0", DYN, loc), @@ -2127,7 +2434,8 @@ function assertThrowsHelper( ]; break; case "predicate": - throw new InternalCompilerError("assert validation callback only enters a no-error helper"); + catchBody = validateStmts(varRef("predicate.0", DYN, loc)); + break; } } diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c5f0a2105..b982c9d66 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3,8 +3,10 @@ import { arrayClassView, lowerArrayClassStaticCall, } from "./builtins/array-classes.js"; +import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; +import { lowerDateToPrimitiveCall } from "./builtins/dates.js"; import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js"; import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; import { lowerDynStreamMemberCall } from "./dyn-streams.js"; @@ -1057,6 +1059,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { } const bi = lowerer.builtinImportOf(expr.expression); if (bi) return lowerBuiltinCall(lowerer, expr, bi, loc); + // A constant bound to an assert method (`const ok = assert.ok`) called + // directly is that assertion, with the message Node quotes the call for. + { + const alias = assertFunctionAliasOf(lowerer, expr.expression); + const served = alias ? lowerer.lowerAssertModuleCall(expr, alias, loc) : null; + if (served) return served; + } // The assert module binding called DIRECTLY (`assert(x)` — a default // import or the CJS `const assert = require("assert")`): Node's // module object IS assert.ok; namespace-import bindings fence inside @@ -1822,6 +1831,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // The element spelling of an unsupported native Web method must fence // before lowering the callee into a checked-dynamic keyed read. if (ts.isElementAccessExpression(expr.expression)) { + const datePrimitive = lowerDateToPrimitiveCall(lowerer, expr, expr.expression); + if (datePrimitive) return datePrimitive; const timersDispose = lowerMockTimersElementCall(lowerer, expr, expr.expression); if (timersDispose) return timersDispose; const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr); @@ -2462,6 +2473,9 @@ function lowerNumberConstructorValue( }; const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc); if (optionalNumber) return optionalNumber; + // A Date's number hint answers its time value. + if (arg.type.kind === "date") + return { kind: "libCall", fn: "date.valueOf", args: [arg], type: F64, loc }; // A program class instance converts through its own methods. const classNumber = classToNumberConversion(lowerer, arg, argNode); if (classNumber) return classNumber; @@ -8709,7 +8723,12 @@ function lowerObjectStaticCall( (probed && isDynTypedRefType(probed.type)) || (probed?.type.kind === "object" && lowerer.errorHierarchyClassOf(probed.type.className)) || (probed && constructor && (constructor.callableBase || hasRuntimeStatics(constructor))) || - (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) + (probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) || + // null, undefined and unions of untyped JavaScript values: the same + // probe, whose ToObject throws Node's TypeError on nullish. + (probed && + (isUnitType(probed.type) || probed.type.kind === "union") && + lowerer.dynConvertible(probed.type)) ) { const loc = locOf(call); const receiver = lowerer.coerceToExpected(probed, DYN); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 28982ddfa..566986b00 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -489,6 +489,10 @@ export interface ClassInfo { localPrototypeData?: true; prototypeMethodValues?: Map; prototypeReflectionReady?: true; + /** The global that records at run time that the prototype view was + * populated by reflection, so it holds its own `constructor` until the + * program deletes it (prototypeReflectedFlag). */ + prototypeReflectedFlag?: string; prototypeObservedMethodsReady?: true; /** The globals holding each observed method's original prototype value * (set when the prototype data is built), by method name. A typed call @@ -10484,6 +10488,29 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [arg], type: F64, loc }; return { kind: "libCall", fn: "date.newMs", args: [time], type: DATE_T, loc }; } + // A union (`Date | string | number`) or checked-dynamic value: the + // runtime's constructor reads a Date's time value, parses a string + // and converts anything else to a primitive and a number. + if ( + (arg.type.kind === "union" || arg.type.kind === "dyn") && + lowerer.dynConvertible(arg.type) + ) { + const value: IrExpr = { + kind: "libCall", + fn: "date.nativeNewValue", + args: [ + { + kind: "dynArrLit", + elems: [lowerer.coerceToExpected(arg, DYN)], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + return { kind: "libCall", fn: "date.checkedValue", args: [value], type: DATE_T, loc }; + } lowerer.noLowering( `new Date of '${lowerer.fmt(arg.type)}' values`, args[0]!, diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 6a6499313..a2b505370 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2377,6 +2377,7 @@ export function typeofAnswer(arm: IrType): string | null { case "set": case "generator": case "regex": + case "date": case "bytes": case "url": case "searchParams": diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts index f837cc0af..ccfa4ad39 100644 --- a/packages/compiler/src/frontend/lowering/lower-generators.ts +++ b/packages/compiler/src/frontend/lowering/lower-generators.ts @@ -183,13 +183,9 @@ export function lowerGenMethodCall( ); } arg = lowerer.lowerExpr(argNode); - if (arg.type.kind === "date") { - lowerer.unsupported( - "SC1090", - argNode, - ".throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead)", - ); - } + // A Date throws as its checked-dynamic handle, the same Date object + // (the throw statement's rule). + if (arg.type.kind === "date") arg = lowerer.coerceToExpected(arg, DYN); if (arg.type.kind === "void" || arg.type.kind === "caught" || isUnitType(arg.type)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 48bb0f9e6..f1ccaebc8 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1,5 +1,6 @@ import { keyedHelperName } from "./stable-names.js"; import { perKeyOrder } from "./key-order-witness.js"; +import { classPrototypeView } from "./class-prototypes.js"; import { dynUndefinedExpr, boolLit, @@ -3527,8 +3528,14 @@ function lowerUtilCallableCall( member === "inherits" && expr.arguments.nodes[1] !== undefined ? builtinClassKindOf(lowerer, expr.arguments.nodes[1]) : null; + // A superCtor class whose prototype has a complete view (a module-level + // JavaScript class named directly) passes that view to the runtime. + const superView = + member === "inherits" && builtinBase === null && expr.arguments.nodes[1] !== undefined + ? classPrototypeView(lowerer, expr.arguments.nodes[1], loc) + : null; for (const [index, arg] of expr.arguments.nodes.slice(0, classArgs).entries()) { - if (index === 1 && builtinBase !== null) continue; + if (index === 1 && (builtinBase !== null || superView !== null)) continue; if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval") lowerer.noLowering( `util.${member} over a class constructor`, @@ -3560,7 +3567,9 @@ function lowerUtilCallableCall( for (const arg of expr.arguments.nodes.slice(arity)) stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc }); const type = fn === "util.inherits" ? VOID : DYN; - const result: IrExpr = { kind: "libCall", fn, args, type, loc }; + const result: IrExpr = superView + ? { kind: "libCall", fn: "util.inheritsPrototype", args: [...args, superView], type, loc } + : { kind: "libCall", fn, args, type, loc }; return { kind: "seqExpr", stmts, result, type: result.type, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index dc9d3def8..3b5be4d23 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -1630,16 +1630,22 @@ export function lowerStaticAbortControllerCall( call: ts.CallExpression, ): IrExpr | null { const access = call.expression; + // An optional chain (`controller?.abort()`) lowers the call again once it + // has narrowed the receiver (Lowerer.chainHandled). if ( - call.questionDotToken || + (call.questionDotToken && !lowerer.chainHandled.has(call)) || call.arguments.nodes.some((argument) => ts.isSpreadElement(argument)) || (!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) || - access.questionDotToken || + (access.questionDotToken && !lowerer.chainHandled.has(access)) || staticResponseMemberName(lowerer, access) !== "abort" ) { return null; } - const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(access.expression)); + const receiverTs = lowerer.checker.getBaseTypeOfLiteralType( + access.questionDotToken + ? lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression)) + : lowerer.typeOf(access.expression), + ); const symbol = receiverTs.getAliasSymbol() ?? receiverTs.getSymbol(); if ( symbol === undefined || diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index c50676863..295a56459 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1624,8 +1624,47 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ "bytes", "func", "regex", + "date", ]); +/** A `return` at the top level of a CommonJS module (preflight admits it + * there only): it ends the module's evaluation, and its value is + * evaluated and discarded, as Node's module wrapper does. Top-level + * bindings and export assignments after it would be left uninitialized + * where Node leaves them in their temporal dead zone or absent, so a + * module with any keeps a refusal. Null for a return inside a function. */ +function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + let top: ts.Node = stmt; + while (top.parent !== undefined && !ts.isSourceFile(top.parent)) { + if (ts.isFunctionLike(top.parent) || ts.isClassStaticBlockDeclaration(top.parent)) return null; + top = top.parent; + } + const sf = top.parent; + if (sf === undefined || !ts.isSourceFile(sf)) return null; + const stmts = sf.statements.nodes; + const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1); + if ( + later.some( + (s) => + ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null, + ) + ) { + lowerer.unsupported( + "SC1090", + stmt, + "a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it", + ); + } + const loc = locOf(stmt); + const ret: IrStmt = { kind: "return", value: null, loc }; + if (!stmt.expression) return ret; + return { + kind: "block", + body: [{ kind: "exprStmt", expr: lowerer.lowerExpr(stmt.expression), loc }, ret], + loc, + }; +} + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) }; @@ -1735,6 +1774,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt if (ts.isForOfStatement(stmt)) return lowerer.lowerForOf(stmt); if (ts.isLabeledStatement(stmt)) return lowerLabeled(lowerer, stmt); if (ts.isReturnStatement(stmt)) { + const moduleReturn = lowerModuleLevelReturn(lowerer, stmt); + if (moduleReturn) return moduleReturn; // Implicit-any instance RETURN INFERENCE (function-returns.ts // resolveInferredReturn): the value lowers BARE and the statement // records itself — the post-pass settles the instance's return type @@ -1828,9 +1869,7 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt return { kind: "rethrow", localId: caughtLocal.id, loc: locOf(stmt) }; } // Exception-representable values move into the runtime's exception - // cell. `throw f()` of a void call has no value; Date has numeric - // storage internally, but cannot preserve its object kind through the - // untyped catch boundary. Reject both rather than changing JS identity. + // cell. `throw f()` of a void call has no value. let value = lowerer.lowerExpr(stmt.expression); if (value.type.kind === "void") lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression)); @@ -1842,19 +1881,12 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt } else if (value.type.kind === "record" && lowerer.dynConvertible(value.type)) { value = { kind: "dynFrom", value, liveRef: true, type: DYN, loc: locOf(stmt.expression) }; } else if (THROWN_AS_DYN.has(value.type.kind) && lowerer.dynConvertible(value.type)) { - // Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes and - // unions over them keep their identity and contents the same way: - // the catch binding, `typeof`, `instanceof`, `Array.isArray`, - // inspection and typed views read the thrown object. + // Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes, + // Dates and unions over them keep their identity and contents the + // same way: the catch binding, `typeof`, `instanceof`, + // `Array.isArray`, inspection and typed views read the thrown object. value = lowerer.coerceInto(stmt.expression, value, DYN); } - if (value.type.kind === "date") { - lowerer.unsupported( - "SC1090", - stmt.expression, - "throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead)", - ); - } // A dyn throw rides the REF cell arm (scr_throw_ref over the checked-dynamic tree // node — identity preserved through catch bindings, caughtToDyn // passes it back by reference). The JS-lane traced-call shape: @@ -5450,7 +5482,12 @@ export function lowerVarDecl( if (isTimeValueDateDeclaration(lowerer, decl)) { const loc = locOf(decl); const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T); - const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet); + const local = lowerer.declareLocal( + decl.name, + decl.name.text, + F64, + isLet || lowerer.timeValueDateWrites.has(decl), + ); lowerer.timeValueDateLocals.add(local); const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc }; return { kind: "varDecl", localId: local.id, init, loc }; diff --git a/packages/compiler/src/frontend/lowering/lowerer.test.ts b/packages/compiler/src/frontend/lowering/lowerer.test.ts index 7fbb9661f..f36695234 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.test.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.test.ts @@ -239,7 +239,7 @@ test("optional union widening keeps refused and missing contracts current", () = unionId: lowerer.unions.recursiveRef(recursive, () => "Recursive"), }; expect(lowerer.withUndefinedArmOf(pending)).not.toBeNull(); - lowerer.unions.finalizeRecursive(recursive, [{ kind: "date" }, STRING]); + lowerer.unions.finalizeRecursive(recursive, [{ kind: "promise", inner: F64 }, STRING]); expect(lowerer.withUndefinedArmOf(pending)).toBeNull(); expect(lowerer.withUndefinedArmOf(pending)).toBeNull(); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d93534115..91b812597 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -600,7 +600,11 @@ import { lowerTestMethodCall, lowerTestCtxProperty, } from "./lower-test.js"; -import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js"; +import { + assertFunctionValue, + lowerAssertModuleCall, + lowerAssertDirectCall, +} from "./lower-assert.js"; import type { AssertInstanceOptions } from "./assert-instances.js"; import { lowerUtilModuleCall } from "./lower-inspect.js"; import { lowerSystemErrorMapValue } from "./lower-system-errors.js"; @@ -2369,6 +2373,9 @@ export class Lowerer { * Date's time value instead of a Date object (date-values.ts). */ readonly timeValueDateDecisions = new Map(); readonly timeValueDateLocals = new Set(); + /** Declarations of those locals that a setter call writes + * (date-values.ts). */ + readonly timeValueDateWrites = new Set(); /** Record bindings whose flow class reaches storage outside the class: * they keep the declared layout, so an initializer from unchecked reads * converts once instead of the binding adopting the widened variant. */ @@ -3917,7 +3924,8 @@ export class Lowerer { /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array * of those), names checked protocol identity, is an owned runtime - * handle (a Worker or MessagePort, a fetch Headers/Request/Response, or + * handle (a Worker or MessagePort, a fetch Headers/Request/Response or + * AbortController/AbortSignal, or * a node:crypto ECDH, DiffieHellman or DiffieHellmanGroup), or is a * WeakMap/WeakSet (native weak collections live in checked-dynamic * storage, as locals of those types do); `any` fields stay refused. */ @@ -14024,6 +14032,8 @@ export class Lowerer { * deliberately narrower than table membership because many table rows * are only dispatch sentinels for call-site-specific lowering. */ lowerBuiltinCallableValue(bi: { module: string; member: string }, loc: SrcLoc): IrExpr | null { + const assertion = assertFunctionValue(bi, loc); + if (assertion) return assertion; if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc); if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member)) return lowerUtilTypeValue(this, bi.member, loc); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 90acabce6..1b4c7efbe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -873,6 +873,12 @@ export const OBJECT_CALLABLE_VALUES: Record result: DYN, valueParams: exactValueParams(DYN, DYN), }, + hasOwn: { + fn: "dyn.hasOwnComputed", + params: [DYN, DYN], + result: BOOL, + valueParams: exactValueParams(DYN, DYN), + }, keys: { fn: "dyn.objKeys", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, values: { fn: "dyn.objValues", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, entries: { fn: "dyn.objEntries", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) }, @@ -2338,6 +2344,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ ], note: "zero arguments, one milliseconds/date-string argument, or local-time year/month fields; values are the read-only TimeClip scalar slice", }, + { + id: "stdlib.date.setters", + kind: "stdlib", + name: "Date.prototype setters", + fns: ["date.setFields", "date.timeClip", "date.store"], + note: "setTime and the local and UTC field setters, with their optional arguments", + }, { id: "stdlib.date.UTC", kind: "stdlib", diff --git a/packages/compiler/src/frontend/npm-static-prune.ts b/packages/compiler/src/frontend/npm-static-prune.ts index 84a58d561..f02a6e0f6 100644 --- a/packages/compiler/src/frontend/npm-static-prune.ts +++ b/packages/compiler/src/frontend/npm-static-prune.ts @@ -11,6 +11,7 @@ import { npmStaticPackageOfPath } from "./npm-static.js"; import { trackedReadFile } from "./input-tracker.js"; import { npmPackageNameOf } from "./workspace-registry.js"; import { typeOnlyNamedImport } from "./type-only-imports.js"; +import { moduleSourceCandidates } from "./module-source-candidates.js"; export function isPrunedNpmReexport(program: ts.Program, stmt: ts.ExportDeclaration): boolean { return program.analysis.prunedNpmReexports.has(stmt); @@ -117,6 +118,21 @@ function pureExternalPackage( type Demand = Set | null; // null requests the entire namespace +/** One module edge of a package file, resolved once per plan. */ +interface ModuleEdge { + spec: string; + dep: ts.SourceFile | null; +} + +/** A file's runtime module edges: its import declarations with the names + * they request (null: the whole module), its `export … from` declarations, + * and its `import()` and `require()` calls with one string argument. */ +interface ModuleEdges { + imports: (ModuleEdge & { names: readonly string[] | null })[]; + reexports: (ModuleEdge & { stmt: ts.ExportDeclaration })[]; + calls: ModuleEdge[]; +} + /** Package files outside the demanded module graph are absent from the * executable program. All ordinary project files retain their preflight * behavior, including files not imported by the entry. */ @@ -151,6 +167,70 @@ export function planNpmStaticReexports( } }; + // The demand walk visits a file again whenever another of its names is + // requested, and every purity check walks the files it reaches: both read + // these edges instead of resolving the file's syntax each time. + const edgeMemo = new Map(); + const edgesOf = (sf: ts.SourceFile): ModuleEdges => { + const cached = edgeMemo.get(sf); + if (cached !== undefined) return cached; + const edges: ModuleEdges = { imports: [], reexports: [], calls: [] }; + for (const stmt of sf.statements.nodes) { + if (ts.isImportDeclaration(stmt)) { + if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue; + const clause = stmt.importClause; + if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; + const spec = stmt.moduleSpecifier.text; + let names: string[] | null = null; + if ( + clause !== undefined && + (clause.namedBindings === undefined || + !( + ts.isNamespaceImport(clause.namedBindings) || + (ts.isNamedImports(clause.namedBindings) && + clause.namedBindings.elements.nodes.length === 0) + )) && + // `import { type A }` still loads the module, like `import {}`. + !typeOnlyNamedImport(stmt) + ) { + names = []; + if (clause.name !== undefined) names.push("default"); + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const element of clause.namedBindings.elements.nodes) { + if (!element.isTypeOnly) names.push((element.propertyName ?? element.name).text); + } + } + } + edges.imports.push({ spec, dep: resolveEdge(sf, spec), names }); + } else if (ts.isExportDeclaration(stmt)) { + // `export { type A } from` still loads the module under Node's + // type stripping; only `export type … from` erases. + if ( + stmt.isTypeOnly || + stmt.moduleSpecifier === undefined || + !ts.isStringLiteral(stmt.moduleSpecifier) + ) + continue; + const spec = stmt.moduleSpecifier.text; + edges.reexports.push({ spec, dep: resolveEdge(sf, spec), stmt }); + } + } + for (const node of moduleSourceCandidates(program, sf).calls) { + if (node.arguments.nodes.length !== 1) continue; + const arg = node.arguments.nodes[0]; + if (arg === undefined || !ts.isStringLiteralLike(arg)) continue; + const kind = + node.expression.kind === ts.SyntaxKind.ImportKeyword + ? "import" + : ts.isIdentifier(node.expression) && node.expression.text === "require" + ? "require" + : null; + if (kind !== null) edges.calls.push({ spec: arg.text, dep: resolveEdge(sf, arg.text, kind) }); + } + edgeMemo.set(sf, edges); + return edges; + }; + for (const sf of files) { if (npmStaticPackageOfPath(sf.fileName) === null) request(sf, null); } @@ -178,57 +258,65 @@ export function planNpmStaticReexports( return false; } }; + // An edge this program does not resolve: the conservative + // whole-dependency promise of the external package it names. + const externalPure = (sf: ts.SourceFile, spec: string): boolean => { + const resolved = resolveBareModule(sf.fileName, spec, "js-only"); + const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile); + const root = + pkg === null || resolved === null + ? null + : packageRootJsonPath(resolved.typesFile, pkg, packageJsons); + return root !== null && pureExternalPackage(root, packageJsons); + }; // Check the actual imported closure: metadata for one file never vouches // for its side-effectful descendants, even across a package or a cycle. - const pureModuleTree = (root: ts.SourceFile): boolean => { - const cached = pureMemo.get(root); - if (cached !== undefined) return cached; - const visited = new Set(); - const visit = (sf: ts.SourceFile): boolean => { - if (visited.has(sf)) return true; - if (pureMemo.get(sf) === false || !filePure(sf)) return false; - visited.add(sf); - const edges: { spec: string; kind?: "import" | "require" }[] = []; - for (const stmt of sf.statements.nodes) { - if ( - ts.isImportDeclaration(stmt) && - stmt.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword && - ts.isStringLiteral(stmt.moduleSpecifier) - ) - edges.push({ spec: stmt.moduleSpecifier.text }); - else if ( - ts.isExportDeclaration(stmt) && - !stmt.isTypeOnly && - stmt.moduleSpecifier && - ts.isStringLiteral(stmt.moduleSpecifier) - ) - edges.push({ spec: stmt.moduleSpecifier.text }); + // A file is pure when every file it reaches is. The walk finds the + // strongly connected components of the module graph (Tarjan): a finished + // component is pure as a whole. On the first impure file the walk stops, + // and every file still on its stack reaches that file, so each is impure. + const order = new Map(); + const stack: ts.SourceFile[] = []; + /** The lowest order the file's walk reaches (Infinity for a finished + * component), or -1 once it reaches an impure file. */ + const visit = (sf: ts.SourceFile): number => { + const known = pureMemo.get(sf); + if (known !== undefined) return known ? Infinity : -1; + const seen = order.get(sf); + if (seen !== undefined) return seen; + if (!filePure(sf)) { + pureMemo.set(sf, false); + return -1; + } + const own = order.size; + order.set(sf, own); + stack.push(sf); + let low = own; + const edges = edgesOf(sf); + for (const list of [edges.imports, edges.reexports, edges.calls]) { + for (const edge of list) { + const reached = + edge.dep !== null ? visit(edge.dep) : externalPure(sf, edge.spec) ? low : -1; + if (reached < 0) return -1; + if (reached < low) low = reached; } - ts.walkPreorder(sf, (node) => { - if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return; - const arg = node.arguments.nodes[0]; - if (!arg || !ts.isStringLiteralLike(arg)) return; - if (node.expression.kind === ts.SyntaxKind.ImportKeyword) - edges.push({ spec: arg.text, kind: "import" }); - else if (ts.isIdentifier(node.expression) && node.expression.text === "require") - edges.push({ spec: arg.text, kind: "require" }); - }); - return edges.every((edge) => { - const dep = resolveEdge(sf, edge.spec, edge.kind); - if (dep !== null) return visit(dep); - const resolved = resolveBareModule(sf.fileName, edge.spec, "js-only"); - const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile); - const root = - pkg === null || resolved === null - ? null - : packageRootJsonPath(resolved.typesFile, pkg, packageJsons); - return root !== null && pureExternalPackage(root, packageJsons); - }); - }; - const pure = visit(root); - if (pure) for (const sf of visited) pureMemo.set(sf, true); - else pureMemo.set(root, false); - return pure; + } + if (low === own) { + for (;;) { + const member = stack.pop()!; + order.delete(member); + pureMemo.set(member, true); + if (member === sf) break; + } + } + return low; + }; + const pureModuleTree = (root: ts.SourceFile): boolean => { + if (visit(root) >= 0) return true; + for (const sf of stack) pureMemo.set(sf, false); + stack.length = 0; + order.clear(); + return false; }; const canPrune = ( sf: ts.SourceFile, @@ -245,78 +333,32 @@ export function planNpmStaticReexports( return names !== undefined && names !== null && !names.has(stmt.exportClause.name.text); }; + // A file is queued again only when its demand grows. Its imports and + // calls request the same modules every time; only a namespace re-export + // the new demand names stops being prunable. + const walked = new Set(); while (queue.length > 0) { const sf = queue.shift()!; - for (const stmt of sf.statements.nodes) { - if (ts.isImportDeclaration(stmt)) { - if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue; - const clause = stmt.importClause; - if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue; - const dep = resolveEdge(sf, stmt.moduleSpecifier.text); - if ( - clause === undefined || - (clause.namedBindings !== undefined && - (ts.isNamespaceImport(clause.namedBindings) || - (ts.isNamedImports(clause.namedBindings) && - clause.namedBindings.elements.nodes.length === 0))) || - // `import { type A }` still loads the module, like `import {}`. - typeOnlyNamedImport(stmt) - ) { - request(dep, null); - } else { - if (clause.name !== undefined) request(dep, "default"); - if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { - for (const element of clause.namedBindings.elements.nodes) { - if (!element.isTypeOnly) request(dep, (element.propertyName ?? element.name).text); - } - } - } - } else if (ts.isExportDeclaration(stmt)) { - // `export { type A } from` still loads the module under Node's - // type stripping; only `export type … from` erases. - if ( - stmt.isTypeOnly || - stmt.moduleSpecifier === undefined || - !ts.isStringLiteral(stmt.moduleSpecifier) - ) - continue; - const dep = resolveEdge(sf, stmt.moduleSpecifier.text); - if (canPrune(sf, stmt, dep)) continue; - request(dep, null); + const edges = edgesOf(sf); + const first = !walked.has(sf); + walked.add(sf); + if (first) { + for (const edge of edges.imports) { + if (edge.names === null) request(edge.dep, null); + else for (const name of edge.names) request(edge.dep, name); } } - ts.walkPreorder(sf, (node) => { - if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return undefined; - const arg = node.arguments.nodes[0]; - if (arg === undefined || !ts.isStringLiteralLike(arg)) return undefined; - if ( - node.expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(node.expression) && node.expression.text === "require") - ) { - request( - resolveEdge( - sf, - arg.text, - node.expression.kind === ts.SyntaxKind.ImportKeyword ? "import" : "require", - ), - null, - ); - } - return undefined; - }); + for (const edge of edges.reexports) { + if (canPrune(sf, edge.stmt, edge.dep)) continue; + request(edge.dep, null); + } + if (first) for (const edge of edges.calls) request(edge.dep, null); } const pruned = new Set(); for (const sf of demanded.keys()) { - for (const stmt of sf.statements.nodes) { - if ( - !ts.isExportDeclaration(stmt) || - stmt.moduleSpecifier === undefined || - !ts.isStringLiteral(stmt.moduleSpecifier) - ) - continue; - if (canPrune(sf, stmt, resolveEdge(sf, stmt.moduleSpecifier.text))) pruned.add(stmt); - } + for (const edge of edgesOf(sf).reexports) + if (canPrune(sf, edge.stmt, edge.dep)) pruned.add(edge.stmt); } program.analysis.prunedNpmReexports.clear(); for (const stmt of pruned) program.analysis.prunedNpmReexports.add(stmt); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index bf75127c4..25caecb64 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -140,6 +140,7 @@ import { } from "./type-only-imports.js"; import { moduleSourceCandidates } from "./module-source-candidates.js"; import { planDeclProjection, type DeclProjection } from "./decl-projection.js"; +import { reportShapes } from "./shape-inference/index.js"; import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js"; import { containsJsx, @@ -1260,6 +1261,10 @@ export function loadProgram( npmStaticPackages.length > 0, projector, ); + // Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES). + const shapes = process.env["SCRIPTC_DEBUG_SHAPES"]; + if (shapes !== undefined && shapes !== "" && shapes !== "0" && npmStaticPackages.length > 0) + reportShapes(load.program, shapes); return { ...load, services, @@ -5358,6 +5363,16 @@ function preflight7(load: LoadResult): { return new RegExp(`\\btypeof\\s*\\(?\\s*${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(sf.text); }; + // A `return` at the top level of a CommonJS module (TS1108): Node runs + // the module as a function body, so the return ends the module's + // evaluation. ES modules and TypeScript keep the error. + const cjsTopLevelReturnSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { + if (d.code !== 1108 || d.fileName === undefined || !isJsSourceFileName(d.fileName)) + return false; + const sf = p.getSourceFile(d.fileName); + return sf !== undefined && isCjsJsFile7(sf, p); + }; + const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => { if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined) return false; @@ -5404,6 +5419,7 @@ function preflight7(load: LoadResult): { !isNpmStaticSubclassArgument(p, d) && !workspaceImplicitAnySuppressed(p, d) && !typeofTestedNameSuppressed(p, d) && + !cjsTopLevelReturnSuppressed(p, d) && !jsdocTypeSuppressed(p, d, commentDup), ); }; diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts new file mode 100644 index 000000000..19d3cac22 --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -0,0 +1,3230 @@ +/* Shape inference (results/design-shape-inference/DESIGN.md), stage S1: + * a whole-program allocation-site points-to analysis over the JavaScript of + * opted-in npm packages and the user files that call into them. + * + * Stage S1 is report-only: the analysis runs when SCRIPTC_DEBUG_SHAPES is + * set and prints its plan (report.ts). Nothing it computes reaches the + * lowering yet, so every program compiles exactly as before. + * + * The analysis (DESIGN.md §4): + * - Andersen-style inclusion constraints, solved with a worklist. Locals + * and parameters are one value node per binding (flow-insensitive in S1); + * heap fields are one node per (atom, name); arrays one element node. + * - Calls resolve by the callee's atoms: functions, class constructors, + * methods by the receiver's class chain (inherited members, getters and + * setters, `this.constructor`), closures stored in fields, `bind`, and + * builtin summaries for the common library surface. + * - Escape: an atom escapes when it reaches code the analysis cannot see + * (a ⊤ callee, a builtin without a summary, a store into a ⊤ object). + * Escaped functions run with ⊤ arguments; everything reachable from an + * escaped atom escapes. + * - Census: writes and reflection whose receiver may be ⊤ apply to every + * escaped atom (§5.4); the report uses them to decide which allocation + * sites could become native records (§5.5). */ +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFile } from "../program.js"; +import { npmStaticPackageOfPath } from "../npm-static.js"; +import { AtomTable, MODELED_GLOBALS, STRING_METHOD_RESULTS, type Atom } from "./domain.js"; + +type Node = ts.Node; + +export interface ValueNode { + readonly id: number; + readonly atoms: Set; + readonly succ: Set; + /** Constraints to run for each atom that arrives. */ + readonly onAtom: ((atom: Atom) => void)[]; + /** Every atom that arrives escapes. */ + escapeSink: string | null; +} + +export interface FunctionInfo { + readonly node: Node; + /** The analysis context: "" or a method's receiver (`C`). */ + readonly ctx: string; + /** The function whose clone the context is (bindings inside it are per context). */ + readonly root: Node | null; + /** Bodies are walked on first call: code that never runs adds no facts. */ + walked: boolean; + readonly label: string; + readonly params: number[]; + /** Index of a rest parameter, or -1. */ + readonly rest: number; + readonly thisNode: number; + readonly ret: number; + readonly isArrow: boolean; + readonly isAsync: boolean; + readonly isGenerator: boolean; + /** Reads `arguments` other than through `...arguments` forwarding. */ + usesArguments: boolean; + /** `f(...arguments)` forwarding: one node per argument position. */ + argPos: number[] | null; + /** The class whose member this function is (method, accessor, constructor). */ + readonly ownerClass: Node | null; + readonly inPackage: boolean; +} + +export interface LiteralSite { + readonly atom: Atom; + readonly node: Node; + readonly kind: "object" | "array"; + /** Static keys in creation order; null when a spread or computed key makes them unknown. */ + readonly keys: string[] | null; + readonly hasAccessor: boolean; + readonly hasProto: boolean; + readonly hasComputedKey: boolean; + readonly spreads: number[]; + readonly inPackage: boolean; +} + +export interface Op { + readonly kind: + | "read" + | "write" + | "computedRead" + | "computedWrite" + | "delete" + | "freeze" + | "seal" + | "defineProperty" + | "setPrototypeOf" + | "assign" + | "proxy" + | "lengthWrite"; + readonly recv: number; + readonly name: string | null; + readonly node: Node; +} + +export interface CallEdge { + readonly caller: FunctionInfo | null; + readonly callee: FunctionInfo; + readonly inLoop: boolean; + /** The call (or `new`, getter read, builtin callback) site. */ + readonly site: Node; +} + +export interface ShapeAnalysis { + readonly atoms: AtomTable; + readonly nodes: ValueNode[]; + readonly functions: Map; + readonly sites: LiteralSite[]; + readonly ops: Op[]; + readonly calls: CallEdge[]; + readonly escaped: Map; + readonly topWriteNames: Map; + readonly topComputedWrites: Node[]; + /** Fields per atom: name → node id. */ + readonly fields: Map>; + readonly elements: Map; + readonly top: Atom; + readonly files: readonly ts.SourceFile[]; + readonly packageFiles: number; + readonly checker: ts.TypeChecker; + readonly budgetExceeded: boolean; + readonly steps: number; + readonly topCause: ReadonlyMap; + readonly nodeLabel: ReadonlyMap; + /** Functions unknown code may call (their atoms escaped). */ + readonly escapedFunctions: ReadonlySet; + /** ES5 constructors: each function's default prototype object. */ + readonly protoOf: ReadonlyMap; +} + +/** Array.prototype members: other names on an array are own properties. */ +const ARRAY_METHODS: ReadonlySet = new Set([ + "at", + "concat", + "copyWithin", + "entries", + "every", + "fill", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "pop", + "push", + "reduce", + "reduceRight", + "reverse", + "shift", + "slice", + "some", + "sort", + "splice", + "toLocaleString", + "toReversed", + "toSorted", + "toSpliced", + "toString", + "unshift", + "values", + "with", +]); +const MAX_STEPS = 20_000_000; + +function list(arr: { readonly nodes: readonly T[] } | undefined): readonly T[] { + return arr?.nodes ?? []; +} + +function isStatic(node: Node): boolean { + const mods = (node as { modifiers?: { nodes: readonly Node[] } }).modifiers; + return list(mods).some((m) => m.kind === ts.SyntaxKind.StaticKeyword); +} + +function unparen(node: ts.Expression): ts.Expression { + let n = node; + for (;;) { + if (ts.isParenthesizedExpression(n)) n = n.expression; + else if (ts.isNonNullExpression(n)) n = n.expression; + else if (ts.isAsExpression(n) || ts.isSatisfiesExpression(n)) n = n.expression; + else return n; + } +} + +function propertyNameText(name: Node): string | null { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text; + if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text; + if (ts.isNumericLiteral(name)) return String(Number(name.text)); + if (ts.isComputedPropertyName(name)) { + const e = unparen(name.expression); + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; + if (ts.isNumericLiteral(e)) return String(Number(e.text)); + } + return null; +} + +function shortFile(fileName: string): string { + const norm = fileName.split("\\").join("/"); + const nm = norm.lastIndexOf("/node_modules/"); + if (nm >= 0) { + const rest = norm.slice(nm + "/node_modules/".length).split("/"); + const pkgLen = rest[0]?.startsWith("@") ? 2 : 1; + const pkg = rest.slice(0, pkgLen).join("/"); + return `${pkg}/${rest[rest.length - 1]}`; + } + return norm.slice(norm.lastIndexOf("/") + 1); +} + +export function locLabel(node: Node): string { + const sf = node.getSourceFile(); + const { line } = ts.getLineAndCharacterOfPosition(sf, node.getStart(sf)); + return `${shortFile(sf.fileName)}:${line + 1}`; +} + +/** Whether a source file belongs to an opted-in package's JavaScript. */ +export function isPackageJsFile(sf: ts.SourceFile): boolean { + return isJsSourceFile(sf) && npmStaticPackageOfPath(sf.fileName) !== null; +} + +function isLoop(node: Node): boolean { + return ( + ts.isForStatement(node) || + ts.isForOfStatement(node) || + ts.isForInStatement(node) || + ts.isWhileStatement(node) || + ts.isDoStatement(node) + ); +} + +const LOOP_CALLBACK_METHODS: ReadonlySet = new Set([ + "map", + "forEach", + "filter", + "some", + "every", + "find", + "findIndex", + "findLast", + "findLastIndex", + "reduce", + "reduceRight", + "flatMap", + "sort", + "toSorted", + "from", + "replace", + "replaceAll", +]); + +export function analyzeShapes(program: ts.Program, checker: ts.TypeChecker): ShapeAnalysis { + return new Analyzer(program, checker).run(); +} + +class Analyzer { + readonly atoms = new AtomTable(); + readonly nodes: ValueNode[] = []; + readonly functions = new Map(); + readonly sites: LiteralSite[] = []; + private readonly siteAtoms = new Set(); + readonly ops: Op[] = []; + readonly calls: CallEdge[] = []; + readonly escaped = new Map(); + readonly topWriteNames = new Map(); + readonly topComputedWrites: Node[] = []; + readonly fields = new Map>(); + readonly elements = new Map(); + readonly top: Atom; + private readonly bindings = new Map(); + private readonly exprMemo = new Map(); + /** Current analysis context and the function whose clone it is. */ + private ctx = ""; + private ctxRoot: Node | null = null; + private readonly classInfo = new Map(); + private readonly boundCalls = new Set(); + private readonly escapedFunctionCalls = new Set(); + private readonly worklist: [number, Atom][] = []; + private steps = 0; + private budgetExceeded = false; + private currentFn: FunctionInfo | null = null; + private loopDepth = 0; + private files: ts.SourceFile[] = []; + private packageFiles = 0; + private readonly prims = new Map(); + /** Per call site: the calling function and whether the site is in a loop + * (constraints fire during solving, after the walk has moved on). */ + private readonly siteCtx = new Map(); + private forceLoop = false; + + noteSite(site: Node): void { + if (!this.siteCtx.has(site)) + this.siteCtx.set(site, { caller: this.currentFn, inLoop: this.loopDepth > 0 }); + } + + /** Builtin calls that invoke their callbacks once per value + * (JSON.parse revivers, JSON.stringify replacers and toJSON methods), + * which may run after the call's own handling. */ + private readonly repeatingSites = new Set(); + + edgeFor(site: Node, callee: FunctionInfo): void { + const ctx = this.siteCtx.get(site); + this.calls.push({ + caller: ctx?.caller ?? null, + callee, + inLoop: (ctx?.inLoop ?? false) || this.forceLoop || this.repeatingSites.has(site), + site, + }); + } + + constructor( + private readonly program: ts.Program, + private readonly checker: ts.TypeChecker, + ) { + this.top = this.atoms.intern("top", () => ({ kind: "top", label: "⊤" })); + } + + run(): ShapeAnalysis { + for (const sf of this.program.getSourceFiles()) { + if (sf.isDeclarationFile) continue; + const name = sf.fileName.split("\\").join("/"); + const inPackage = isPackageJsFile(sf); + if (!inPackage && name.includes("/node_modules/")) continue; + this.files.push(sf); + if (inPackage) this.packageFiles++; + } + for (const sf of this.files) this.checker.prefetchSourceFile(sf); + for (const sf of this.files) this.moduleBody(sf); + this.finish(); + return { + atoms: this.atoms, + nodes: this.nodes, + functions: this.functions, + sites: this.sites, + ops: this.ops, + calls: this.calls, + escaped: this.escaped, + topWriteNames: this.topWriteNames, + topComputedWrites: this.topComputedWrites, + fields: this.fields, + elements: this.elements, + top: this.top, + files: this.files, + packageFiles: this.packageFiles, + checker: this.checker, + budgetExceeded: this.budgetExceeded, + steps: this.steps, + topCause: this.topCause, + nodeLabel: this.nodeLabel, + escapedFunctions: this.escapedFunctionCalls, + protoOf: this.protoOf, + }; + } + + // ---- value nodes ------------------------------------------------------- + + newNode(): number { + const id = this.nodes.length; + this.nodes.push({ id, atoms: new Set(), succ: new Set(), onAtom: [], escapeSink: null }); + return id; + } + + /** Debugging (SCRIPTC_DEBUG_SHAPES_WHY): where each node's ⊤ came from. */ + readonly topCause = new Map(); + readonly nodeLabel = new Map(); + private cause: number | null = null; + private readonly traceTop = process.env["SCRIPTC_DEBUG_SHAPES_WHY"] !== undefined; + + add(node: number, atom: Atom): void { + const n = this.nodes[node]!; + if (n.atoms.has(atom)) return; + n.atoms.add(atom); + if (this.traceTop && atom === this.top && !this.topCause.has(node)) + this.topCause.set( + node, + this.cause ?? + (new Error().stack ?? "") + .split("\n") + .slice(2, 4) + .map((l) => l.trim()) + .join(" < "), + ); + this.worklist.push([node, atom]); + } + + edge(from: number, to: number): void { + if (from === to) return; + const n = this.nodes[from]!; + if (n.succ.has(to)) return; + n.succ.add(to); + const saved = this.cause; + this.cause = from; + for (const a of n.atoms) this.add(to, a); + this.cause = saved; + } + + on(node: number, fn: (atom: Atom) => void): void { + const n = this.nodes[node]!; + n.onAtom.push(fn); + for (const a of [...n.atoms]) fn(a); + } + + sink(node: number, reason: string): void { + const n = this.nodes[node]!; + if (n.escapeSink !== null) return; + n.escapeSink = reason; + for (const a of [...n.atoms]) this.escape(a, reason); + } + + constNode(atom: Atom): number { + const n = this.newNode(); + this.add(n, atom); + return n; + } + + prim(name: string): Atom { + let a = this.prims.get(name); + if (a === undefined) { + a = this.atoms.intern(`prim:${name}`, () => ({ kind: "prim", label: name })); + this.prims.set(name, a); + } + return a; + } + + /** ES5 constructor functions: the function's default prototype object, + * and which function each `new F()` instance atom belongs to. */ + readonly protoOf = new Map(); + readonly fnOfInstance = new Map(); + protoAtom(fn: Atom): Atom { + let p = this.protoOf.get(fn); + if (p === undefined) { + p = this.atoms.intern(`proto:${this.nodeKey(fn.node!)}:${fn.ctx ?? ""}`, () => ({ + kind: "object", + label: `${fn.label}.prototype`, + node: fn.node, + })); + this.protoOf.set(fn, p); + this.add(this.field(p, "constructor"), fn); + if (this.escaped.has(fn)) this.escape(p, `via ${fn.label}`); + } + return p; + } + + builtin(path: string, receiver?: Atom): Atom { + const key = receiver === undefined ? `builtin:${path}` : `builtin:${path}@${receiver.id}`; + return this.atoms.intern(key, () => ({ kind: "builtin", label: path, path, receiver })); + } + + solve(): void { + while (this.worklist.length > 0) { + if (++this.steps > MAX_STEPS) { + this.budgetExceeded = true; + return; + } + const [id, atom] = this.worklist.pop()!; + const n = this.nodes[id]!; + this.cause = id; + for (const s of n.succ) this.add(s, atom); + this.cause = null; + for (const f of n.onAtom) f(atom); + if (n.escapeSink !== null) this.escape(atom, n.escapeSink); + } + } + + // ---- heap ---------------------------------------------------------------- + + field(atom: Atom, name: string): number { + let m = this.fields.get(atom); + if (m === undefined) { + m = new Map(); + this.fields.set(atom, m); + } + let id = m.get(name); + if (id === undefined) { + id = this.newNode(); + m.set(name, id); + if (this.traceTop) this.nodeLabel.set(id, `${atom.label}.${name}`); + const byName = this.fieldReaders.get(atom); + const readers = byName?.get(name); + if (readers !== undefined) { + for (const dst of readers) this.edge(id, dst); + byName!.delete(name); + } + const reason = this.escaped.get(atom); + if (reason !== undefined) { + this.sink(id, reason); + if (this.topComputedWrites.length > 0 || this.topWriteNames.has(name)) + this.add(id, this.top); + } + } + return id; + } + + element(atom: Atom): number { + let id = this.elements.get(atom); + if (id === undefined) { + id = this.newNode(); + this.elements.set(atom, id); + const reason = this.escaped.get(atom); + if (reason !== undefined) { + this.sink(id, reason); + if (this.topComputedWrites.length > 0) this.add(id, this.top); + } + } + return id; + } + + escape(atom: Atom, reason: string): void { + if (atom.kind === "prim" || atom.kind === "top") return; + if (atom.kind === "builtin" && atom.receiver === undefined) return; + if (this.escaped.has(atom)) return; + this.escaped.set(atom, reason); + const via = `via ${atom.label}`; + // Unknown code reaches a constructor's prototype through the function + // and through its instances. + const ctor = atom.kind === "function" ? atom : this.fnOfInstance.get(atom); + if (ctor !== undefined && ctor.node !== undefined && (atom !== ctor || this.protoOf.has(ctor))) + this.escape(this.protoAtom(ctor), via); + const fm = this.fields.get(atom); + if (fm !== undefined) { + for (const [name, id] of fm) { + this.sink(id, via); + if (this.topComputedWrites.length > 0 || this.topWriteNames.has(name)) + this.add(id, this.top); + } + } + const el = this.elements.get(atom); + if (el !== undefined) { + this.sink(el, via); + if (this.topComputedWrites.length > 0) this.add(el, this.top); + } + if (atom.receiver !== undefined) this.escape(atom.receiver, via); + if (atom.kind === "function") { + const info = this.infoOfAtom(atom); + if (info !== undefined) this.escapeFunction(info, via); + } + if ((atom.kind === "instance" || atom.kind === "class") && atom.node !== undefined) { + // Unknown code may call any member with any arguments. + for (const member of this.classMembers(atom.node, atom.kind === "class")) { + const info = this.functionInfo(member, this.ownerOf(member), this.receiverCtx(atom)); + this.add(info.thisNode, atom); + this.escapeFunction(info, via); + } + } + if (atom.kind === "promise") this.sink(this.field(atom, "value"), via); + } + + /** A function unknown code may call: ⊤ arguments and receiver; its + * results escape. */ + escapeFunction(info: FunctionInfo, reason: string): void { + if (this.escapedFunctionCalls.has(info)) return; + this.escapedFunctionCalls.add(info); + this.activate(info); + for (const p of info.params) this.add(p, this.top); + if (!info.isArrow && info.ownerClass === null) this.add(info.thisNode, this.top); + this.sink(info.ret, reason); + } + + /** Property names that untyped code writes on receivers that may be ⊤ (census). */ + noteTopWrite(name: string | null, node: Node): void { + if (name === null) { + const first = this.topComputedWrites.length === 0; + this.topComputedWrites.push(node); + if (first) { + for (const atom of [...this.escaped.keys()]) { + for (const id of (this.fields.get(atom) ?? new Map()).values()) + this.add(id, this.top); + const el = this.elements.get(atom); + if (el !== undefined) this.add(el, this.top); + } + } + return; + } + if (this.topWriteNames.has(name)) return; + this.topWriteNames.set(name, node); + for (const atom of [...this.escaped.keys()]) { + const id = this.fields.get(atom)?.get(name); + if (id !== undefined) this.add(id, this.top); + } + } + + // ---- classes --------------------------------------------------------------- + + /** The base class declaration of `decl`, via the checker; null at a + * builtin base or none; undefined when unresolved. */ + baseClass(decl: Node): Node | null | undefined { + const heritage = list((decl as ts.ClassLikeDeclaration).heritageClauses).find( + (c) => c.token === ts.SyntaxKind.ExtendsKeyword, + )?.types?.nodes[0]; + if (heritage === undefined) return null; + const e = unparen(heritage.expression); + if (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e)) return undefined; + let d = this.resolve(ts.isIdentifier(e) ? e : e.name); + // `var C = class C { … }` (bundler output) + if ( + d !== undefined && + ts.isVariableDeclaration(d) && + d.initializer !== undefined && + ts.isClassExpression(unparen(d.initializer)) + ) + d = unparen(d.initializer); + if (d !== undefined && ts.isClassLikeDeclaration(d)) return d; + if (d !== undefined && d.getSourceFile().isDeclarationFile) return null; + return undefined; + } + + classChain(decl: Node): Node[] { + const out: Node[] = []; + let cur: Node | null | undefined = decl; + while (cur && out.length < 64 && !out.includes(cur)) { + out.push(cur); + cur = this.baseClass(cur); + } + return out; + } + + /** Method, accessor and constructor declarations of a class chain. */ + classMembers(decl: Node, statics: boolean): Node[] { + const out: Node[] = []; + for (const c of this.classChain(decl)) { + for (const m of list((c as ts.ClassLikeDeclaration).members)) { + if (isStatic(m) !== statics) continue; + if ( + ts.isMethodDeclaration(m) || + ts.isGetAccessorDeclaration(m) || + ts.isSetAccessorDeclaration(m) + ) + out.push(m); + else if (ts.isPropertyDeclaration(m) && m.initializer !== undefined) { + const init = unparen(m.initializer); + if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) out.push(init); + } + } + } + return out; + } + + lookupMember( + decl: Node, + name: string, + statics: boolean, + ): { + kind: "method" | "getter" | "setter" | "both"; + get?: Node; + set?: Node; + method?: Node; + } | null { + for (const c of this.classChain(decl)) { + let get: Node | undefined; + let set: Node | undefined; + for (const m of list((c as ts.ClassLikeDeclaration).members)) { + if (isStatic(m) !== statics) continue; + const mn = (m as { name?: Node }).name; + if (mn === undefined || propertyNameText(mn) !== name) continue; + if (ts.isMethodDeclaration(m)) return { kind: "method", method: m }; + if (ts.isGetAccessorDeclaration(m)) get = m; + if (ts.isSetAccessorDeclaration(m)) set = m; + if (ts.isPropertyDeclaration(m)) return null; // an own field: data + } + if (get || set) + return { + kind: get && set ? "both" : get ? "getter" : "setter", + ...(get ? { get } : {}), + ...(set ? { set } : {}), + }; + } + return null; + } + + // ---- symbols ----------------------------------------------------------------- + + resolve(id: Node): Node | undefined { + let sym = this.checker.getSymbolAtLocation(id); + if (sym === undefined) return undefined; + if (sym.flags & ts.SymbolFlags.Alias) { + try { + sym = this.checker.getAliasedSymbol(sym); + } catch { + return undefined; + } + } + return sym.valueDeclaration ?? sym.declarations[0]; + } + + binding(decl: Node): number { + const key = this.ctxKey(decl); + let id = this.bindings.get(key); + if (id === undefined) { + id = this.newNode(); + this.bindings.set(key, id); + if (this.traceTop) + this.nodeLabel.set( + id, + `binding ${(decl as { name?: { text?: string } }).name?.text ?? "?"}@${locLabel(decl)}${this.ctx !== "" ? ` [${this.ctx}]` : ""}`, + ); + } + return id; + } + + /** Bindings and expressions inside the function being cloned belong to + * the clone's context; everything else to the default context. */ + ctxKey(node: Node): string { + const root = this.ctxRoot; + const inside = + root !== null && + node.getSourceFile() === root.getSourceFile() && + node.pos >= root.pos && + node.end <= root.end; + return `${inside ? this.ctx : ""}|${this.nodeKey(node)}`; + } + + receiverCtx(atom: Atom): string { + return atom.kind === "instance" || atom.kind === "class" ? `C${atom.id}` : ""; + } + + infoOfAtom(atom: Atom): FunctionInfo | undefined { + if (atom.node === undefined) return undefined; + return this.functions.get(`${atom.ctx ?? ""}|${this.nodeKey(atom.node)}`); + } + + // ---- functions --------------------------------------------------------------- + + functionInfo(node: Node, ownerClass: Node | null, ctxArg = ""): FunctionInfo { + // A nested closure walked inside a clone shares the clone's context. + const inherit = + ctxArg === "" && + this.ctxRoot !== null && + node.getSourceFile() === this.ctxRoot.getSourceFile() && + node.pos >= this.ctxRoot.pos && + node.end <= this.ctxRoot.end; + const ctx = ctxArg !== "" ? ctxArg : inherit ? this.ctx : ""; + const root = ctxArg !== "" ? node : inherit ? this.ctxRoot : null; + const fkey = `${ctx}|${this.nodeKey(node)}`; + const existing = this.functions.get(fkey); + if (existing !== undefined) return existing; + const savedCtx = this.ctx; + const savedRoot = this.ctxRoot; + this.ctx = ctx; + this.ctxRoot = root; + const fn = node as ts.FunctionLikeDeclaration; + const isArrow = ts.isArrowFunction(node); + const params: number[] = []; + let rest = -1; + for (const p of list(fn.parameters)) { + const id = this.binding(p); + if (p.dotDotDotToken !== undefined) rest = params.length; + params.push(id); + } + const mods = list((fn as { modifiers?: { nodes: readonly Node[] } }).modifiers); + const sf = node.getSourceFile(); + const nameNode = (fn as { name?: Node }).name; + const name = nameNode !== undefined ? (propertyNameText(nameNode) ?? "") : null; + const owner = ownerClass !== null ? this.className(ownerClass) : null; + const label = `${owner !== null ? `${owner}.` : ""}${name ?? (ts.isConstructorDeclaration(node) ? "constructor" : "")}@${locLabel(node)}`; + const info: FunctionInfo = { + node, + ctx, + root, + walked: false, + label: ctx === "" ? label : `${label} [${this.ctxLabel(ctx)}]`, + params, + rest, + thisNode: isArrow && this.currentFn !== null ? this.currentFn.thisNode : this.newNode(), + ret: this.newNode(), + isArrow, + isAsync: mods.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + isGenerator: (fn as { asteriskToken?: Node }).asteriskToken !== undefined, + usesArguments: false, + argPos: null, + ownerClass, + inPackage: isPackageJsFile(sf), + }; + this.functions.set(fkey, info); + this.ctx = savedCtx; + this.ctxRoot = savedRoot; + return info; + } + + /** Walks a function's body the first time it can run. */ + activate(info: FunctionInfo): void { + if (info.walked) return; + info.walked = true; + if (ts.isSourceFile(info.node) || ts.isClassLikeDeclaration(info.node)) return; + const fn = info.node as ts.FunctionLikeDeclaration; + const savedCtx = this.ctx; + const savedRoot = this.ctxRoot; + const saved = this.currentFn; + const savedLoop = this.loopDepth; + this.ctx = info.ctx; + this.ctxRoot = info.root; + this.currentFn = info; + this.loopDepth = 0; + for (const p of list(fn.parameters)) { + if (p.initializer !== undefined) this.edge(this.expr(p.initializer), this.binding(p)); + if (p.dotDotDotToken !== undefined) { + const arr = this.atoms.intern(`rest:${this.nodes.length}`, () => ({ + kind: "array", + label: `rest@${locLabel(p)}`, + node: p, + })); + this.add(this.binding(p), arr); + } + this.bindPattern(p.name, this.binding(p)); + } + const body = fn.body; + if (body !== undefined) { + if (ts.isBlock(body)) this.stmts(list(body.statements)); + else this.edge(this.expr(body as ts.Expression), info.ret); + } + this.currentFn = saved; + this.loopDepth = savedLoop; + this.ctx = savedCtx; + this.ctxRoot = savedRoot; + if (info.usesArguments) { + for (const p of info.params) this.add(p, this.top); + } + } + + /** The atom of a function node (memoized by its info). */ + fnAtom(node: Node, ownerClass: Node | null, ctx = ""): Atom { + const info = this.functionInfo(node, ownerClass, ctx); + return this.atoms.intern(`fn:${info.ret}`, () => ({ + kind: "function", + label: info.label, + node, + ctx: info.ctx, + })); + } + + ctxLabel(ctx: string): string { + if (ctx.startsWith("C")) return `this=${this.atoms.atoms[Number(ctx.slice(1))]?.label ?? ctx}`; + return ctx; + } + + className(decl: Node): string { + const own = (decl as ts.ClassLikeDeclaration).name?.text; + if (own !== undefined) return own; + const p = decl.parent; + if (p !== undefined && ts.isVariableDeclaration(p) && ts.isIdentifier(p.name)) + return p.name.text; + return ``; + } + + classAtoms(decl: Node): { cls: Atom; inst: Atom } { + const name = this.className(decl); + const cls = this.atoms.intern(`class:${this.nodeKey(decl)}`, () => ({ + kind: "class", + label: `class ${name}`, + node: decl, + })); + const inst = this.atoms.intern(`inst:${this.nodeKey(decl)}`, () => ({ + kind: "instance", + label: name, + node: decl, + })); + this.ensureClass(decl); + return { cls, inst }; + } + + private readonly nodeKeys = new Map(); + nodeKey(node: Node): number { + let k = this.nodeKeys.get(node); + if (k === undefined) { + k = this.nodeKeys.size; + this.nodeKeys.set(node, k); + } + return k; + } + + /** Registers a class: member functions, field initializers (as a pseudo + * function run at construction) and static initializers. */ + ensureClass(decl: Node): { initFn: FunctionInfo; staticInit: FunctionInfo } { + const existing = this.classInfo.get(decl); + if (existing !== undefined) return existing; + const c = decl as ts.ClassLikeDeclaration; + const label = this.className(decl); + const mk = (suffix: string): FunctionInfo => ({ + node: decl, + ctx: "", + root: null, + walked: true, + label: `${label}.${suffix}@${locLabel(decl)}`, + params: [], + rest: -1, + thisNode: this.newNode(), + ret: this.newNode(), + isArrow: false, + isAsync: false, + isGenerator: false, + usesArguments: false, + argPos: null, + ownerClass: decl, + inPackage: isPackageJsFile(decl.getSourceFile()), + }); + const entry = { initFn: mk(""), staticInit: mk("") }; + this.classInfo.set(decl, entry); + const cls = this.atoms.intern(`class:${this.nodeKey(decl)}`, () => ({ + kind: "class", + label: `class ${label}`, + node: decl, + })); + this.add(entry.staticInit.thisNode, cls); + const saved = this.currentFn; + for (const m of list(c.members)) { + const statics = isStatic(m); + if ( + ts.isMethodDeclaration(m) || + ts.isGetAccessorDeclaration(m) || + ts.isSetAccessorDeclaration(m) || + ts.isConstructorDeclaration(m) + ) { + continue; + } + if (ts.isPropertyDeclaration(m)) { + const name = propertyNameText(m.name); + const init = statics ? entry.staticInit : entry.initFn; + this.currentFn = init; + const value = + m.initializer !== undefined + ? this.exprIn(m.initializer, decl) + : this.constNode(this.prim("undefined")); + if (name !== null) this.storeConstraint(init.thisNode, name, value, m); + continue; + } + if (ts.isClassStaticBlockDeclaration(m)) { + this.currentFn = entry.staticInit; + this.stmts(list(m.body.statements)); + } + } + this.currentFn = saved; + return entry; + } + + /** An initializer expression: arrow functions in field initializers take + * the instance as `this` and belong to the class. */ + exprIn(e: ts.Expression, ownerClass: Node): number { + const u = unparen(e); + if (ts.isArrowFunction(u) || ts.isFunctionExpression(u)) { + const n = this.newNode(); + this.add(n, this.fnAtom(u, ts.isArrowFunction(u) ? null : ownerClass)); + return n; + } + return this.expr(e); + } + + /** `new C(args)` on a class atom: the instance, the field initializers of + * every class in the chain, and the constructor (or the implicit one). */ + construct( + decl: Node, + args: number[], + spread: boolean, + dst: number, + site: Node, + inst?: Atom, + ): void { + const atoms = this.classAtoms(decl); + const instance = inst ?? atoms.inst; + this.add(dst, instance); + this.callConstructorChain(decl, instance, args, spread, site); + } + + callConstructorChain( + decl: Node, + instance: Atom, + args: number[], + spread: boolean, + site: Node, + ): void { + const key = `ctor:${this.nodeKey(decl)}:${instance.id}:${this.nodeKey(site)}`; + if (this.boundCalls.has(key)) return; + this.boundCalls.add(key); + const entry = this.ensureClass(decl); + this.add(entry.initFn.thisNode, instance); + const ctor = list((decl as ts.ClassLikeDeclaration).members).find((m) => + ts.isConstructorDeclaration(m), + ); + if (ctor !== undefined) { + const info = this.functionInfo(ctor, decl, this.receiverCtx(instance)); + this.activate(info); + this.add(info.thisNode, instance); + this.bindArgs(info, args, spread); + this.edgeFor(site, info); + return; + } + const base = this.baseClass(decl); + if (base) this.callConstructorChain(base, instance, args, spread, site); + } + + bindArgs(info: FunctionInfo, args: number[], spread: boolean): void { + if (info.argPos !== null) { + for (let i = 0; i < info.argPos.length; i++) { + if (spread || i >= info.argPos.length - 1) { + for (const a of args.slice(i)) this.edge(a, info.argPos[i]!); + if (spread) this.add(info.argPos[i]!, this.top); + } else if (i < args.length) this.edge(args[i]!, info.argPos[i]!); + else this.add(info.argPos[i]!, this.prim("undefined")); + } + } + if (spread) { + for (const p of info.params) this.add(p, this.top); + for (const a of args) this.sink(a, `spread into ${info.label}`); + return; + } + for (let i = 0; i < info.params.length; i++) { + if (i === info.rest) { + const restAtoms = [...this.nodes[info.params[i]!]!.atoms]; + for (const ra of restAtoms) + for (let j = i; j < args.length; j++) this.edge(args[j]!, this.element(ra)); + break; + } + if (i < args.length) this.edge(args[i]!, info.params[i]!); + else this.add(info.params[i]!, this.prim("undefined")); + } + } + + /** Call of a function atom with a receiver node. */ + callFunction( + info: FunctionInfo, + thisNode: number | null, + args: number[], + spread: boolean, + dst: number, + site: Node, + ): void { + // `() => Object.create(null)` helpers: each call site gets its own + // dictionary, as if the helper were inlined. + if (this.isFactory(info.node)) { + const d = this.atoms.intern(`dict:${this.nodeKey(site)}`, () => ({ + kind: "dictionary", + label: `Object.create@${locLabel(site)}`, + node: site, + })); + this.add(dst, d); + return; + } + this.activate(info); + const fnNode = info.node; + const key = `call:${info.ctx}|${this.nodeKey(fnNode)}:${this.nodeKey(site)}:${thisNode ?? -1}:${dst}`; + if (this.boundCalls.has(key)) return; + this.boundCalls.add(key); + if (thisNode !== null && !info.isArrow) this.edge(thisNode, info.thisNode); + this.bindArgs(info, args, spread); + if (info.isAsync) { + const p = this.promiseAtom(fnNode, `async ${info.label}`); + this.awaitInto(info.ret, this.field(p, "value")); + this.add(dst, p); + } else if (info.isGenerator) { + this.add(dst, this.top); + } else this.edge(info.ret, dst); + this.edgeFor(site, info); + } + + private readonly factories = new Map(); + isFactory(node: Node): boolean { + let r = this.factories.get(node); + if (r === undefined) { + r = false; + const fn = node as ts.FunctionLikeDeclaration; + let e: Node | undefined = fn.body; + if (e !== undefined && ts.isBlock(e)) { + const st = e.statements.nodes; + e = st.length === 1 && ts.isReturnStatement(st[0]!) ? st[0]!.expression : undefined; + } + if (e !== undefined && !ts.isSourceFile(node)) { + const u = unparen(e as ts.Expression); + r = + ts.isCallExpression(u) && + u.expression.getText() === "Object.create" && + u.arguments.nodes.length === 1 && + u.arguments.nodes[0]!.kind === ts.SyntaxKind.NullKeyword; + } + this.factories.set(node, r); + } + return r; + } + + promiseAtom(site: Node, label: string): Atom { + return this.atoms.intern(`promise:${this.nodeKey(site)}`, () => ({ + kind: "promise", + label: `Promise@${label}`, + node: site, + })); + } + + /** dst ⊇ awaited(src). */ + awaitInto(src: number, dst: number): void { + this.on(src, (a) => { + if (a.kind === "promise") this.edge(this.field(a, "value"), dst); + else this.add(dst, a); + }); + } + + syntheticArray(site: Node, tag: string): Atom { + return this.atoms.intern(`arr:${tag}:${this.nodeKey(site)}`, () => ({ + kind: "array", + label: `${tag}[]@${locLabel(site)}`, + node: site, + })); + } + + // ---- statements ---------------------------------------------------------------- + + moduleBody(sf: ts.SourceFile): void { + const info: FunctionInfo = { + node: sf, + ctx: "", + root: null, + walked: true, + label: ``, + params: [], + rest: -1, + thisNode: this.constNode(this.prim("undefined")), + ret: this.newNode(), + isArrow: false, + isAsync: false, + isGenerator: false, + usesArguments: false, + argPos: null, + ownerClass: null, + inPackage: isPackageJsFile(sf), + }; + this.functions.set(`|${this.nodeKey(sf)}`, info); + this.currentFn = info; + this.stmts(list(sf.statements)); + this.currentFn = null; + } + + stmts(stmts: readonly Node[]): void { + for (const s of stmts) this.stmt(s); + } + + stmt(s: Node): void { + if (ts.isVariableStatement(s)) { + for (const d of list(s.declarationList.declarations)) this.varDecl(d); + return; + } + if (ts.isExpressionStatement(s)) { + this.expr(s.expression); + return; + } + if (ts.isReturnStatement(s)) { + if (s.expression !== undefined && this.currentFn !== null) + this.edge(this.expr(s.expression), this.currentFn.ret); + return; + } + if (ts.isFunctionDeclaration(s)) { + const b = this.binding(s); + const saved = this.currentFn; + this.currentFn = null; + this.add(b, this.fnAtom(s, null)); + this.currentFn = saved; + return; + } + if (ts.isClassDeclaration(s)) { + const b = this.binding(s); + this.add(b, this.classAtoms(s).cls); + return; + } + if (isLoop(s)) { + if (ts.isForOfStatement(s) || ts.isForInStatement(s)) { + const src = this.expr(s.expression); + const target = this.newNode(); + if (ts.isForInStatement(s)) this.add(target, this.prim("string")); + else this.iterateInto(src, target, s); + const init = s.initializer; + if (ts.isVariableDeclarationList(init)) { + for (const d of list(init.declarations)) { + this.edge(target, this.binding(d)); + this.bindPattern(d.name, this.binding(d)); + } + } else this.assignTo(init as ts.Expression, target); + } + this.loopDepth++; + if (ts.isForStatement(s)) { + if (s.initializer !== undefined) { + if (ts.isVariableDeclarationList(s.initializer)) + for (const d of list(s.initializer.declarations)) this.varDecl(d); + else this.expr(s.initializer as ts.Expression); + } + if (s.condition !== undefined) this.expr(s.condition); + if (s.incrementor !== undefined) this.expr(s.incrementor); + } + if (ts.isWhileStatement(s) || ts.isDoStatement(s)) this.expr(s.expression); + const body = (s as { statement?: Node }).statement; + if (body !== undefined) this.stmt(body); + this.loopDepth--; + return; + } + if (ts.isTryStatement(s)) { + this.stmts(list(s.tryBlock.statements)); + if (s.catchClause !== undefined) { + const v = s.catchClause.variableDeclaration; + if (v !== undefined) { + this.add(this.binding(v), this.top); + this.bindPattern(v.name, this.binding(v)); + } + this.stmts(list(s.catchClause.block.statements)); + } + if (s.finallyBlock !== undefined) this.stmts(list(s.finallyBlock.statements)); + return; + } + if (ts.isThrowStatement(s)) { + this.sink(this.expr(s.expression), `thrown at ${locLabel(s)}`); + return; + } + if ( + ts.isImportDeclaration(s) || + ts.isExportDeclaration(s) || + ts.isInterfaceDeclaration(s) || + ts.isTypeAliasDeclaration(s) + ) + return; + if (ts.isExportAssignment(s)) { + this.expr(s.expression); + return; + } + // Blocks, ifs, switches, labels: walk children, evaluating expressions. + ts.forEachChild(s, (child) => { + if (ts.isExpression(child)) this.expr(child); + else this.stmt(child); + return undefined; + }); + } + + varDecl(d: ts.VariableDeclaration): void { + const b = this.binding(d); + if (d.initializer !== undefined) this.edge(this.expr(d.initializer), b); + this.bindPattern(d.name, b); + } + + /** Destructuring: binding elements read from the source node. */ + bindPattern(name: Node, src: number): void { + if (ts.isIdentifier(name)) return; + if (ts.isObjectBindingPattern(name)) { + for (const el of list(name.elements)) { + const b = this.binding(el); + if (el.dotDotDotToken !== undefined) { + this.add(b, this.top); + continue; + } + const keyNode = el.propertyName ?? el.name; + const key = keyNode !== undefined ? propertyNameText(keyNode) : null; + if (key !== null) this.loadConstraint(src, key, b, el); + else this.add(b, this.top); + if (el.initializer !== undefined) this.edge(this.expr(el.initializer), b); + if (el.name !== undefined) this.bindPattern(el.name, b); + } + return; + } + if (ts.isArrayBindingPattern(name)) { + let i = 0; + for (const el of list(name.elements)) { + if (ts.isBindingElement(el)) { + const b = this.binding(el); + if (el.dotDotDotToken !== undefined) this.add(b, this.top); + else this.iterateInto(src, b, el, i); + if (el.initializer !== undefined) this.edge(this.expr(el.initializer), b); + if (el.name !== undefined) this.bindPattern(el.name, b); + } + i++; + } + } + } + + /** Element values of an iterable. */ + iterateInto(src: number, dst: number, site: Node, _index?: number): void { + this.on(src, (a) => { + if (a.kind === "array" || a.kind === "collection") this.edge(this.element(a), dst); + else if (a.kind === "prim" && a.label === "string") this.add(dst, this.prim("string")); + else if (a.kind === "prim" || a.kind === "object" || a.kind === "dictionary") { + /* not iterable (a TypeError): no value. Literals with a + Symbol.iterator member have a computed key; they are G sites. */ + } else { + this.add(dst, this.top); + if (a.kind !== "top") this.escape(a, `iterated at ${locLabel(site)}`); + } + }); + } + + // ---- expressions --------------------------------------------------------------- + + expr(e: ts.Expression): number { + const key = this.ctxKey(e); + const memo = this.exprMemo.get(key); + if (memo !== undefined) return memo; + const id = this.exprUncached(e); + this.exprMemo.set(key, id); + if (this.traceTop && !this.nodeLabel.has(id)) + this.nodeLabel.set( + id, + `expr ${e.getText().slice(0, 40).replace(/\s+/g, " ")}@${locLabel(e)}`, + ); + return id; + } + + private exprUncached(e: ts.Expression): number { + const u = unparen(e); + if (u !== e) return this.expr(u); + switch (e.kind) { + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: + case ts.SyntaxKind.TemplateExpression: + if (ts.isTemplateExpression(e)) + for (const span of list(e.templateSpans)) this.expr(span.expression); + return this.constNode(this.prim("string")); + case ts.SyntaxKind.NumericLiteral: + return this.constNode(this.prim("number")); + case ts.SyntaxKind.BigIntLiteral: + return this.constNode(this.prim("bigint")); + case ts.SyntaxKind.TrueKeyword: + case ts.SyntaxKind.FalseKeyword: + return this.constNode(this.prim("boolean")); + case ts.SyntaxKind.NullKeyword: + return this.constNode(this.prim("null")); + case ts.SyntaxKind.RegularExpressionLiteral: + return this.constNode(this.builtin("RegExp.instance")); + case ts.SyntaxKind.ThisKeyword: + return this.currentFn !== null + ? this.currentFn.thisNode + : this.constNode(this.prim("undefined")); + case ts.SyntaxKind.SuperKeyword: + return this.constNode(this.top); + } + if (ts.isIdentifier(e)) return this.identifier(e); + if (ts.isObjectLiteralExpression(e)) return this.objectLiteral(e); + if (ts.isArrayLiteralExpression(e)) return this.arrayLiteral(e); + if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) { + const n = this.newNode(); + this.add(n, this.fnAtom(e, null)); + if (ts.isFunctionExpression(e) && e.name !== undefined) + this.add(this.binding(e), this.fnAtom(e, null)); + return n; + } + if (ts.isClassExpression(e)) { + const n = this.newNode(); + this.add(n, this.classAtoms(e).cls); + return n; + } + if (ts.isPropertyAccessExpression(e)) { + this.noteSite(e); + // `globalThis.Proxy` and the like are the modeled globals. + const object = unparen(e.expression); + if ( + ts.isIdentifier(object) && + object.text === "globalThis" && + MODELED_GLOBALS.has(e.name.text) + ) { + const d = this.resolve(object); + if (d === undefined || d.getSourceFile().isDeclarationFile) + return this.globalNode(e.name.text); + } + const recv = this.expr(e.expression); + const name = e.name.text; + const dst = this.newNode(); + this.loadConstraint(recv, name, dst, e); + this.ops.push({ kind: "read", recv, name, node: e }); + return dst; + } + if (ts.isElementAccessExpression(e)) { + const recv = this.expr(e.expression); + const key = unparen(e.argumentExpression); + const dst = this.newNode(); + const name = + ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key) ? key.text : null; + if (name !== null) { + this.loadConstraint(recv, name, dst, e); + this.ops.push({ kind: "read", recv, name, node: e }); + } else { + this.expr(e.argumentExpression); + this.computedLoad(recv, dst, e); + this.ops.push({ kind: "computedRead", recv, name: null, node: e }); + } + return dst; + } + if (ts.isCallExpression(e)) return this.call(e); + if (ts.isNewExpression(e)) return this.newExpr(e); + if (ts.isBinaryExpression(e)) return this.binary(e); + if (ts.isConditionalExpression(e)) { + this.expr(e.condition); + const n = this.newNode(); + this.edge(this.expr(e.whenTrue), n); + this.edge(this.expr(e.whenFalse), n); + return n; + } + if (ts.isAwaitExpression(e)) { + const n = this.newNode(); + this.awaitInto(this.expr(e.expression), n); + return n; + } + if (ts.isTypeOfExpression(e)) { + this.expr(e.expression); + return this.constNode(this.prim("string")); + } + if (ts.isVoidExpression(e)) { + this.expr(e.expression); + return this.constNode(this.prim("undefined")); + } + if (ts.isDeleteExpression(e)) { + const target = unparen(e.expression); + if (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) { + const recv = this.expr(target.expression); + const name = ts.isPropertyAccessExpression(target) ? target.name.text : null; + this.ops.push({ kind: "delete", recv, name, node: e }); + } + return this.constNode(this.prim("boolean")); + } + if (ts.isPrefixUnaryExpression(e) || ts.isPostfixUnaryExpression(e)) { + const operand = e.operand; + this.expr(operand); + if ( + e.operator === ts.SyntaxKind.PlusPlusToken || + e.operator === ts.SyntaxKind.MinusMinusToken + ) + this.assignTo(operand, this.constNode(this.prim("number"))); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) + return this.constNode(this.prim("boolean")); + return this.constNode(this.prim("number")); + } + if (ts.isSpreadElement(e)) return this.expr(e.expression); + if (ts.isTaggedTemplateExpression(e)) return this.taggedTemplate(e); + if (ts.isYieldExpression(e)) { + if (e.expression !== undefined) + this.sink(this.expr(e.expression), `yielded at ${locLabel(e)}`); + return this.constNode(this.top); + } + if (ts.isMetaProperty(e)) return this.constNode(this.top); + // Anything else: evaluate children, answer ⊤. + ts.forEachChild(e, (child) => { + if (ts.isExpression(child)) this.expr(child); + return undefined; + }); + return this.constNode(this.top); + } + + identifier(id: ts.Identifier): number { + if (id.text === "undefined") return this.constNode(this.prim("undefined")); + if (id.text === "arguments") { + const fn = this.enclosingNonArrow(); + if (fn !== null) { + const parent = id.parent; + const forwarding = parent !== undefined && ts.isSpreadElement(parent); + if (!forwarding) fn.usesArguments = true; + const arr = this.syntheticArray(id, "arguments"); + for (const p of fn.params) this.edge(p, this.element(arr)); + return this.constNode(arr); + } + } + const decl = this.resolve(id); + if (decl === undefined) return this.globalNode(id.text); + if (decl.getSourceFile().isDeclarationFile) return this.globalNode(id.text); + if (!this.files.includes(decl.getSourceFile())) return this.constNode(this.top); + if (ts.isFunctionDeclaration(decl)) { + const b = this.binding(decl); + const saved = this.currentFn; + this.currentFn = null; + this.add(b, this.fnAtom(decl, null)); + this.currentFn = saved; + return b; + } + if (ts.isClassDeclaration(decl) || ts.isClassExpression(decl)) { + const b = this.binding(decl); + this.add(b, this.classAtoms(decl).cls); + return b; + } + if (ts.isFunctionExpression(decl)) return this.binding(decl); + if ( + ts.isImportSpecifier(decl) || + ts.isImportClause(decl) || + ts.isNamespaceImport(decl) || + ts.isImportEqualsDeclaration(decl) + ) + return this.constNode(this.top); + if (ts.isSourceFile(decl) || ts.isModuleDeclaration(decl)) return this.namespaceNode(decl); + // A CommonJS export the checker resolved an import or require binding + // to (`module.exports = { name }`, `exports.name = v`): the member of the + // module's exports. + const exported = cjsExportName(decl); + if (exported !== null && decl.getSourceFile() !== id.getSourceFile()) { + const dst = this.newNode(); + this.loadConstraint(this.namespaceNode(decl.getSourceFile()), exported, dst, id); + return dst; + } + return this.narrowed(id, decl, this.binding(decl)); + } + + /** Flow narrowing at guarded references (S1 handles the guards hot code + * uses: `x instanceof C` and calls of predicate functions whose body is + * `… && x instanceof C`, in the true arm of an `if`, `?:` or `&&`). The + * narrowed value keeps the guard's class arms and ⊤. */ + narrowed(id: ts.Identifier, decl: Node, value: number): number { + let child: Node = id; + let p = id.parent; + while (p !== undefined && !ts.isFunctionLike(p) && !ts.isSourceFile(p)) { + let cond: ts.Expression | undefined; + if (ts.isIfStatement(p) && p.thenStatement === child) cond = p.expression; + else if (ts.isConditionalExpression(p) && p.whenTrue === child) cond = p.condition; + else if ( + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + p.right === child + ) + cond = p.left; + if (cond !== undefined) { + const cls = this.guardClass(cond, decl); + if (cls !== null) { + const out = this.newNode(); + this.on(value, (a) => { + if (a.kind === "top") this.add(out, a); + else if ( + cls === "Promise" + ? a.kind === "promise" + : a.kind === "instance" && + a.node !== undefined && + this.classChain(a.node).includes(cls) + ) + this.add(out, a); + }); + return out; + } + } + child = p; + p = p.parent; + } + return value; + } + + /** The class a condition proves `decl`'s value an instance of, if any. */ + guardClass(cond: ts.Expression, decl: Node): Node | "Promise" | null { + const e = unparen(cond); + if (ts.isBinaryExpression(e)) { + if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) + return this.guardClass(e.left, decl) ?? this.guardClass(e.right, decl); + if (e.operatorToken.kind !== ts.SyntaxKind.InstanceOfKeyword) return null; + const l = unparen(e.left); + if (!ts.isIdentifier(l) || this.resolve(l) !== decl) return null; + return this.instanceofTarget(e.right); + } + if ( + ts.isCallExpression(e) && + ts.isIdentifier(unparen(e.expression)) && + e.arguments.nodes.length === 1 + ) { + const arg = unparen(e.arguments.nodes[0]!); + if (!ts.isIdentifier(arg) || this.resolve(arg) !== decl) return null; + const fnDecl = this.resolve(unparen(e.expression)); + let fn: Node | undefined = fnDecl; + if (fn !== undefined && ts.isVariableDeclaration(fn) && fn.initializer !== undefined) + fn = unparen(fn.initializer); + if ( + fn === undefined || + !(ts.isArrowFunction(fn) || ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn)) + ) + return null; + const param = fn.parameters.nodes[0]; + if (param === undefined || !ts.isIdentifier(param.name)) return null; + let body: Node | undefined = fn.body; + if (body !== undefined && ts.isBlock(body)) { + const stmts = body.statements.nodes; + body = + stmts.length === 1 && ts.isReturnStatement(stmts[0]!) ? stmts[0]!.expression : undefined; + } + if (body === undefined) return null; + return this.predicateClass(body as ts.Expression, param); + } + return null; + } + + /** `… && p instanceof C` in a predicate body over parameter `p`. */ + predicateClass(e: ts.Expression, param: Node): Node | "Promise" | null { + const u = unparen(e); + if (!ts.isBinaryExpression(u)) return null; + if (u.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) + return this.predicateClass(u.right, param) ?? this.predicateClass(u.left, param); + if (u.operatorToken.kind !== ts.SyntaxKind.InstanceOfKeyword) return null; + const l = unparen(u.left); + if (!ts.isIdentifier(l) || this.resolve(l) !== param) return null; + return this.instanceofTarget(u.right); + } + + instanceofTarget(right: ts.Expression): Node | "Promise" | null { + const r = unparen(right); + if (!ts.isIdentifier(r)) return null; + const d = this.resolve(r); + if (d === undefined || d.getSourceFile().isDeclarationFile) + return r.text === "Promise" ? "Promise" : null; + return ts.isClassLikeDeclaration(d) ? d : null; + } + + private readonly namespaces = new Map(); + /** Module namespaces: a CommonJS module's `module.exports` value is the + * namespace's `exports` field, and member reads see its members too. */ + private readonly namespaceAtoms = new Set(); + /** `import * as ns`: an object whose fields are the module's exports. */ + namespaceNode(mod: Node): number { + const existing = this.namespaces.get(mod); + if (existing !== undefined) return existing; + const n = this.newNode(); + this.namespaces.set(mod, n); + const atom = this.atoms.intern(`ns:${this.nodeKey(mod)}`, () => ({ + kind: "object", + label: `namespace ${shortFile((mod as ts.SourceFile).fileName ?? "")}`, + node: mod, + })); + this.namespaceAtoms.add(atom); + this.add(n, atom); + const sym = this.checker.getSymbolAtLocation(mod); + if (sym !== undefined) { + for (const ex of this.checker.getExportsOfModule(sym)) { + let target = ex; + if (target.flags & ts.SymbolFlags.Alias) { + try { + target = this.checker.getAliasedSymbol(target); + } catch { + continue; + } + } + const d = target.valueDeclaration ?? target.declarations[0]; + if (d === undefined) continue; + const value = this.declValue(d); + if (value !== null) this.edge(value, this.field(atom, String(ex.escapedName))); + } + } + return n; + } + + declValue(d: Node): number | null { + if (d.getSourceFile().isDeclarationFile || !this.files.includes(d.getSourceFile())) return null; + if (ts.isFunctionDeclaration(d)) { + const b = this.binding(d); + const saved = this.currentFn; + this.currentFn = null; + this.add(b, this.fnAtom(d, null)); + this.currentFn = saved; + return b; + } + if (ts.isClassDeclaration(d)) { + const b = this.binding(d); + this.add(b, this.classAtoms(d).cls); + return b; + } + if (ts.isVariableDeclaration(d) || ts.isBindingElement(d) || ts.isParameter(d)) + return this.binding(d); + if (ts.isSourceFile(d)) return this.namespaceNode(d); + return null; + } + + globalNode(name: string): number { + if (MODELED_GLOBALS.has(name)) return this.constNode(this.builtin(name)); + if (name === "NaN" || name === "Infinity") return this.constNode(this.prim("number")); + return this.constNode(this.top); + } + + enclosingNonArrow(): FunctionInfo | null { + const fn = this.currentFn; + if (fn === null) return null; + if (!fn.isArrow) return fn; + // An arrow's `arguments` is its enclosing function's. + let p: Node | undefined = fn.node.parent; + while (p !== undefined) { + if (ts.isFunctionLike(p) && !ts.isArrowFunction(p)) + return ( + this.functions.get(this.ctxKey(p)) ?? this.functions.get(`|${this.nodeKey(p)}`) ?? null + ); + p = p.parent; + } + return null; + } + + objectLiteral(e: ts.ObjectLiteralExpression): number { + const keys: string[] = []; + let keysKnown = true; + let hasAccessor = false; + let hasProto = false; + let hasComputedKey = false; + const spreads: number[] = []; + const inPackage = isPackageJsFile(e.getSourceFile()); + const label = (): string => + `{${keys.slice(0, 6).join(",")}${keys.length > 6 ? ",…" : ""}${keysKnown ? "" : ",…"}}@${locLabel(e)}`; + // keys first, so the label is complete + for (const p of list(e.properties)) { + if (ts.isSpreadAssignment(p)) { + keysKnown = false; + continue; + } + const name = (p as { name?: Node }).name; + const key = name !== undefined ? propertyNameText(name) : null; + if (key === null) { + hasComputedKey = true; + keysKnown = false; + continue; + } + if (key === "__proto__" && ts.isPropertyAssignment(p) && !ts.isComputedPropertyName(p.name)) + hasProto = true; + if (!keys.includes(key)) keys.push(key); + } + const atom = this.atoms.intern(`obj:${this.nodeKey(e)}`, () => ({ + kind: "object", + label: label(), + node: e, + })); + const n = this.newNode(); + this.add(n, atom); + for (const p of list(e.properties)) { + if (ts.isSpreadAssignment(p)) { + const src = this.expr(p.expression); + spreads.push(src); + this.copyFields(src, atom, p); + continue; + } + if (ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) { + hasAccessor = true; + const info = this.functionInfo(p, null); + this.activate(info); + this.add(info.thisNode, atom); + const key = propertyNameText(p.name); + if (key !== null && ts.isGetAccessorDeclaration(p)) + this.edge(info.ret, this.field(atom, key)); + continue; + } + const name = (p as { name?: Node }).name; + const key = name !== undefined ? propertyNameText(name) : null; + if (name !== undefined && ts.isComputedPropertyName(name)) this.expr(name.expression); + let value: number; + if (ts.isPropertyAssignment(p)) value = this.expr(p.initializer); + else if (ts.isShorthandPropertyAssignment(p)) { + const sym = this.checker.getShorthandAssignmentValueSymbol(p); + const d = sym?.valueDeclaration ?? sym?.declarations[0]; + const id = p.name as ts.Identifier; + value = d !== undefined ? (this.declValue(d) ?? this.identifier(id)) : this.identifier(id); + } else if (ts.isMethodDeclaration(p)) { + const fa = this.fnAtom(p, null); + this.add(this.infoOfAtom(fa)!.thisNode, atom); + value = this.constNode(fa); + } else continue; + if (key !== null) this.edge(value, this.field(atom, key)); + else this.edge(value, this.anyField(atom)); + } + if (!this.siteAtoms.has(atom) && this.siteAtoms.add(atom)) + this.sites.push({ + atom, + node: e, + kind: "object", + keys: keysKnown ? keys : null, + hasAccessor, + hasProto, + hasComputedKey, + spreads, + inPackage, + }); + return n; + } + + /** Values written under unknown keys. */ + anyField(atom: Atom): number { + return this.field(atom, "*"); + } + + /** Loads waiting for an atom's first write of a field, by field name. */ + private readonly fieldReaders = new Map>(); + + /** dst ⊇ the values written to field `name` of `atom`, now and later, + * without creating the field for atoms that have none. */ + fieldInto(atom: Atom, name: string, dst: number): void { + const id = this.fields.get(atom)?.get(name); + if (id !== undefined) { + this.edge(id, dst); + return; + } + let byName = this.fieldReaders.get(atom); + if (byName === undefined) { + byName = new Map(); + this.fieldReaders.set(atom, byName); + } + let readers = byName.get(name); + if (readers === undefined) { + readers = []; + byName.set(name, readers); + } + readers.push(dst); + } + + /** dst ⊇ the values written to `atom` under unknown keys, now and later. */ + unknownKeysInto(atom: Atom, dst: number): void { + this.fieldInto(atom, "*", dst); + } + + copyFields(src: number, dstAtom: Atom, site: Node): void { + this.on(src, (a) => { + if (a.kind === "top") { + this.edge(this.constNode(this.top), this.anyField(dstAtom)); + return; + } + if (a.kind === "prim" || a.kind === "builtin") return; + if (a.kind === "array") { + this.edge(this.element(a), this.anyField(dstAtom)); + return; + } + // Copy every own field the source has, now and later. + const fm = this.fields.get(a); + const copied = new Set(); + const copy = (): void => { + for (const [name, id] of this.fields.get(a) ?? new Map()) { + if (copied.has(name)) continue; + copied.add(name); + this.edge(id, this.field(dstAtom, name)); + } + }; + copy(); + if (fm === undefined) this.fields.set(a, new Map()); + this.lateCopies.push(copy); + if (a.kind === "instance" || a.kind === "dictionary" || a.kind === "collection") + this.edge(this.constNode(this.top), this.anyField(dstAtom)); + void site; + }); + } + + /** Field copies re-run after solving rounds (fields created later). */ + private readonly lateCopies: (() => void)[] = []; + + /** node ⊇ the own field and element values of every value in it, now and + * later: the values JSON.stringify serializes. */ + childrenInto(node: number): void { + this.on(node, (a) => { + if ( + a.kind !== "object" && + a.kind !== "array" && + a.kind !== "instance" && + a.kind !== "dictionary" + ) + return; + const seen = new Set(); + const copy = (): void => { + for (const [name, id] of this.fields.get(a) ?? new Map()) { + if (seen.has(name) || name.startsWith("#")) continue; + seen.add(name); + this.edge(id, node); + } + const el = this.elements.get(a); + if (el !== undefined && !seen.has("[]")) { + seen.add("[]"); + this.edge(el, node); + } + }; + copy(); + this.lateCopies.push(copy); + }); + } + + /** JSON.stringify's call of `value.toJSON(key)` on an object, array or function that may + * have one (a class member, an own field, or a write under an unknown key, once it exists); + * the result goes to `out`. */ + callToJSON( + value: Atom, + key: number, + out: number, + site: ts.CallExpression | ts.TaggedTemplateExpression, + ): void { + if ( + value.kind !== "object" && + value.kind !== "instance" && + value.kind !== "dictionary" && + value.kind !== "array" && + value.kind !== "function" && + value.kind !== "class" + ) + return; + let called = false; + const call = (): void => { + if (called) return; + const fields = this.fields.get(value); + const has = + fields?.has("toJSON") === true || + fields?.has("*") === true || + ((value.kind === "instance" || value.kind === "class") && + this.lookupMember(value.node!, "toJSON", value.kind === "class") !== null); + if (!has) return; + called = true; + const fn = this.newNode(); + this.loadAtom(value, "toJSON", fn, site); + this.invoke(fn, this.constNode(value), [key], false, out, site); + }; + call(); + if (!called) this.lateCopies.push(call); + } + + arrayLiteral(e: ts.ArrayLiteralExpression): number { + const atom = this.atoms.intern(`arr:${this.nodeKey(e)}`, () => ({ + kind: "array", + label: `[]@${locLabel(e)}`, + node: e, + })); + const n = this.newNode(); + this.add(n, atom); + const el = this.element(atom); + let hasHole = false; + for (const item of list(e.elements)) { + if (ts.isOmittedExpression(item)) { + hasHole = true; + continue; + } + if (ts.isSpreadElement(item)) this.iterateInto(this.expr(item.expression), el, item); + else this.edge(this.expr(item), el); + } + if (!this.siteAtoms.has(atom) && this.siteAtoms.add(atom)) + this.sites.push({ + atom, + node: e, + kind: "array", + keys: null, + hasAccessor: false, + hasProto: hasHole, + hasComputedKey: false, + spreads: [], + inPackage: isPackageJsFile(e.getSourceFile()), + }); + return n; + } + + binary(e: ts.BinaryExpression): number { + const op = e.operatorToken.kind; + if (op === ts.SyntaxKind.EqualsToken) { + const value = this.expr(e.right); + this.assignTo(e.left, value); + return value; + } + if ( + op === ts.SyntaxKind.BarBarEqualsToken || + op === ts.SyntaxKind.QuestionQuestionEqualsToken || + op === ts.SyntaxKind.AmpersandAmpersandEqualsToken + ) { + const n = this.newNode(); + this.edge(this.expr(e.left), n); + const value = this.expr(e.right); + this.edge(value, n); + this.assignTo(e.left, value); + return n; + } + if (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) { + this.expr(e.left); + this.expr(e.right); + const result = this.constNode( + op === ts.SyntaxKind.PlusEqualsToken ? this.top : this.prim("number"), + ); + if (op === ts.SyntaxKind.PlusEqualsToken) { + this.nodes[result]!.atoms.clear(); + this.add(result, this.prim("string")); + this.add(result, this.prim("number")); + } + this.assignTo(e.left, result); + return result; + } + if ( + op === ts.SyntaxKind.BarBarToken || + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.AmpersandAmpersandToken + ) { + const n = this.newNode(); + this.edge(this.expr(e.left), n); + this.edge(this.expr(e.right), n); + return n; + } + if (op === ts.SyntaxKind.CommaToken) { + this.expr(e.left); + return this.expr(e.right); + } + const l = this.expr(e.left); + const r = this.expr(e.right); + switch (op) { + case ts.SyntaxKind.EqualsEqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsToken: + case ts.SyntaxKind.LessThanToken: + case ts.SyntaxKind.LessThanEqualsToken: + case ts.SyntaxKind.GreaterThanToken: + case ts.SyntaxKind.GreaterThanEqualsToken: + case ts.SyntaxKind.InstanceOfKeyword: + case ts.SyntaxKind.InKeyword: + if (op === ts.SyntaxKind.InKeyword) + this.ops.push({ kind: "computedRead", recv: r, name: null, node: e }); + return this.constNode(this.prim("boolean")); + case ts.SyntaxKind.PlusToken: { + const n = this.newNode(); + const strOrNum = (a: Atom): void => { + this.add( + n, + a.kind === "prim" && a.label === "number" ? this.prim("number") : this.prim("string"), + ); + if ( + a.kind === "prim" && + (a.label === "boolean" || a.label === "null" || a.label === "undefined") + ) + this.add(n, this.prim("number")); + if (a.kind === "top") this.add(n, this.prim("number")); + }; + this.on(l, strOrNum); + this.on(r, strOrNum); + return n; + } + default: + return this.constNode(this.prim("number")); + } + } + + assignTo(target: ts.Expression, value: number): void { + const t = unparen(target); + if (ts.isIdentifier(t)) { + const decl = this.resolve(t); + if (decl === undefined || decl.getSourceFile().isDeclarationFile) { + this.sink(value, `global write at ${locLabel(t)}`); + return; + } + this.edge(value, this.binding(decl)); + return; + } + if (ts.isPropertyAccessExpression(t)) { + const recv = this.expr(t.expression); + this.storeConstraint(recv, t.name.text, value, t); + this.ops.push({ + kind: t.name.text === "length" ? "lengthWrite" : "write", + recv, + name: t.name.text, + node: t, + }); + return; + } + if (ts.isElementAccessExpression(t)) { + const recv = this.expr(t.expression); + const key = unparen(t.argumentExpression); + if (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) { + this.storeConstraint(recv, key.text, value, t); + this.ops.push({ kind: "write", recv, name: key.text, node: t }); + } else { + this.expr(t.argumentExpression); + this.computedStore(recv, value, t); + this.ops.push({ kind: "computedWrite", recv, name: null, node: t }); + } + return; + } + if (ts.isObjectLiteralExpression(t)) { + for (const p of list(t.properties)) { + if (ts.isPropertyAssignment(p)) { + const key = propertyNameText(p.name); + const n = this.newNode(); + if (key !== null) this.loadConstraint(value, key, n, p); + else this.add(n, this.top); + this.assignTo(p.initializer, n); + } else if (ts.isShorthandPropertyAssignment(p)) { + const n = this.newNode(); + const id = p.name as ts.Identifier; + this.loadConstraint(value, id.text, n, p); + this.assignTo(id, n); + } + } + return; + } + if (ts.isArrayLiteralExpression(t)) { + for (const el of list(t.elements)) { + if (ts.isOmittedExpression(el)) continue; + const n = this.newNode(); + this.iterateInto(value, n, el); + this.assignTo(ts.isSpreadElement(el) ? el.expression : el, n); + } + } + } + + // ---- loads and stores ------------------------------------------------------------ + + loadConstraint(recv: number, name: string, dst: number, site: Node): void { + this.on(recv, (a) => this.loadAtom(a, name, dst, site)); + } + + loadAtom(a: Atom, name: string, dst: number, site: Node): void { + switch (a.kind) { + case "top": + this.add(dst, this.top); + return; + case "prim": + if (name === "length" && a.label === "string") this.add(dst, this.prim("number")); + else if (a.label === "string" || a.label === "number" || a.label === "boolean") + this.add(dst, this.builtin(`${a.label}.${name}`, a)); + return; + case "instance": { + const decl = a.node!; + if (name === "constructor") { + // An own `constructor` field, written by name or under an unknown key, shadows the class. + this.add(dst, this.classAtoms(decl).cls); + this.fieldInto(a, name, dst); + this.unknownKeysInto(a, dst); + return; + } + const member = this.lookupMember(decl, name, false); + if (member?.kind === "method") + this.add( + dst, + this.fnAtom(member.method!, this.ownerOf(member.method!), this.receiverCtx(a)), + ); + if (member !== null && member.get !== undefined) { + const g = this.functionInfo(member.get, this.ownerOf(member.get), this.receiverCtx(a)); + this.activate(g); + this.add(g.thisNode, a); + this.edge(g.ret, dst); + this.edgeFor(site, g); + return; + } + if (this.classChain(decl).some((c) => this.baseClass(c) === undefined)) + this.add(dst, this.top); + this.edge(this.field(a, name), dst); + this.unknownKeysInto(a, dst); + return; + } + case "class": { + const decl = a.node!; + if (name === "prototype" || name === "name") { + this.add(dst, name === "prototype" ? this.top : this.prim("string")); + this.fieldInto(a, name, dst); + this.unknownKeysInto(a, dst); + return; + } + const member = this.lookupMember(decl, name, true); + if (member?.kind === "method") + this.add( + dst, + this.fnAtom(member.method!, this.ownerOf(member.method!), this.receiverCtx(a)), + ); + if (member?.get !== undefined) { + const g = this.functionInfo(member.get, this.ownerOf(member.get), this.receiverCtx(a)); + this.activate(g); + this.add(g.thisNode, a); + this.edge(g.ret, dst); + return; + } + // static fields of the class and its bases + for (const c of this.classChain(decl)) { + const cls = this.classAtoms(c).cls; + this.edge(this.field(cls, name), dst); + this.unknownKeysInto(cls, dst); + } + return; + } + case "function": + // An own field, written by name or under an unknown key, shadows a builtin member. + if (name === "call" || name === "apply" || name === "bind") + this.add(dst, this.builtin(`function.${name}`, a)); + else if (name === "prototype") { + // The default prototype object, or what the program assigns. + this.add(dst, this.protoAtom(a)); + this.edge(this.field(a, "prototype"), dst); + } else if (name === "name") this.add(dst, this.prim("string")); + else if (name === "length") this.add(dst, this.prim("number")); + else { + this.edge(this.field(a, name), dst); + this.unknownKeysInto(a, dst); + return; + } + this.fieldInto(a, name, dst); + this.unknownKeysInto(a, dst); + return; + case "array": + if (name === "length") this.add(dst, this.prim("number")); + else if (/^\d+$/.test(name)) this.edge(this.element(a), dst); + else { + // A named property, written by name or under an unknown key; an own field shadows a method. + if (ARRAY_METHODS.has(name)) { + this.add(dst, this.builtin(`array.${name}`, a)); + this.fieldInto(a, name, dst); + } else this.edge(this.field(a, name), dst); + this.unknownKeysInto(a, dst); + } + return; + case "object": { + this.edge(this.field(a, name), dst); + this.edge(this.anyField(a), dst); + if (this.namespaceAtoms.has(a) && name !== "exports") { + this.loadConstraint(this.field(a, "exports"), name, dst, site); + } + // `new F()` instances read through F's prototype objects. + const fn = this.fnOfInstance.get(a); + if (fn !== undefined) { + const protos = this.newNode(); + this.add(protos, this.protoAtom(fn)); + this.edge(this.field(fn, "prototype"), protos); + this.on(protos, (proto) => { + if (proto.kind === "object") { + // A method stored under a computed key (`F.prototype[k] = fn`) + // may be any name. + this.edge(this.field(proto, name), dst); + this.edge(this.anyField(proto), dst); + } else if (proto.kind === "top") this.add(dst, this.top); + }); + return; + } + if (name === "constructor" || name === "hasOwnProperty" || name === "toString") + this.add(dst, this.builtin(`object.${name}`, a)); + return; + } + case "dictionary": + this.edge(this.anyField(a), dst); + this.edge(this.field(a, name), dst); + return; + case "promise": + if (name === "then" || name === "catch" || name === "finally") + this.add(dst, this.builtin(`promise.${name}`, a)); + return; + case "collection": + if (name === "size") this.add(dst, this.prim("number")); + else this.add(dst, this.builtin(`collection.${name}`, a)); + return; + case "builtin": + this.loadBuiltin(a, name, dst); + return; + } + void site; + } + + ownerOf(member: Node): Node | null { + const p = member.parent; + return p !== undefined && ts.isClassLikeDeclaration(p) ? p : null; + } + + loadBuiltin(a: Atom, name: string, dst: number): void { + const path = a.path ?? ""; + if ( + path === "Number" && + (name === "MAX_SAFE_INTEGER" || + name === "MIN_SAFE_INTEGER" || + name === "EPSILON" || + name === "MAX_VALUE" || + name === "MIN_VALUE" || + name === "POSITIVE_INFINITY" || + name === "NEGATIVE_INFINITY" || + name === "NaN") + ) + this.add(dst, this.prim("number")); + else if (path === "Math" && /^[A-Z0-9_]+$/.test(name)) this.add(dst, this.prim("number")); + else if (path === "Symbol") this.add(dst, this.prim("symbol")); + else if (path === "Error.instance" || path === "error") { + if (name === "message" || name === "stack" || name === "name") + this.add(dst, this.prim("string")); + else this.add(dst, this.top); + } else if ( + a.receiver === undefined && + MODELED_GLOBALS.has(path.split(".")[0]!) && + path.split(".").length < 4 + ) + this.add(dst, this.builtin(`${path}.${name}`)); + else if (path === "RegExp.instance") this.add(dst, this.builtin(`regexp.${name}`, a)); + else this.add(dst, this.top); + } + + storeConstraint(recv: number, name: string, value: number, site: Node): void { + this.on(recv, (a) => { + switch (a.kind) { + case "top": + this.noteTopWrite(name, site); + this.sink(value, `stored into ⊤.${name} at ${locLabel(site)}`); + return; + case "instance": { + const member = this.lookupMember(a.node!, name, false); + if (member?.set !== undefined) { + const s = this.functionInfo(member.set, this.ownerOf(member.set), this.receiverCtx(a)); + this.activate(s); + this.add(s.thisNode, a); + if (s.params[0] !== undefined) this.edge(value, s.params[0]); + return; + } + this.edge(value, this.field(a, name)); + return; + } + case "class": + case "function": + case "object": + case "dictionary": + this.edge(value, this.field(a, name)); + return; + case "array": + if (/^\d+$/.test(name)) this.edge(value, this.element(a)); + else if (name !== "length") this.edge(value, this.field(a, name)); + return; + case "prim": + return; + default: + this.sink(value, `stored into ${a.label} at ${locLabel(site)}`); + } + }); + } + + computedLoad(recv: number, dst: number, site: Node): void { + this.on(recv, (a) => { + switch (a.kind) { + case "top": + this.add(dst, this.top); + return; + case "array": + case "collection": + this.edge(this.element(a), dst); + return; + case "prim": + if (a.label === "string") this.add(dst, this.prim("string")); + return; + case "object": + case "dictionary": + case "instance": + case "class": + case "function": { + // every field, now and later + const seen = new Set(); + const copy = (): void => { + for (const [name, id] of this.fields.get(a) ?? new Map()) { + if (seen.has(name)) continue; + seen.add(name); + this.edge(id, dst); + } + }; + copy(); + this.lateCopies.push(copy); + if (a.kind === "instance" || a.kind === "class") this.add(dst, this.top); + return; + } + default: + this.add(dst, this.top); + } + void site; + }); + } + + computedStore(recv: number, value: number, site: Node): void { + this.on(recv, (a) => { + switch (a.kind) { + case "top": + this.noteTopWrite(null, site); + this.sink(value, `stored into ⊤[k] at ${locLabel(site)}`); + return; + case "array": + // An index, or a named property under an unknown key. + this.edge(value, this.element(a)); + this.edge(value, this.anyField(a)); + return; + case "prim": + return; + case "object": + case "dictionary": + case "instance": + case "class": + case "function": + this.edge(value, this.anyField(a)); + return; + default: + // As a store under a name: a builtin, promise or collection keeps it where it isn't tracked. + this.sink(value, `stored into ${a.label}[k] at ${locLabel(site)}`); + } + }); + } + + // ---- calls --------------------------------------------------------------------------- + + /** tag`…${x}…`: a call of the tag with the template's strings array (an + * array of strings with a `raw` array of strings) and each substitution. */ + taggedTemplate(e: ts.TaggedTemplateExpression): number { + this.noteSite(e); + const tag = unparen(e.tag); + const recv = + ts.isPropertyAccessExpression(tag) && tag.expression.kind !== ts.SyntaxKind.SuperKeyword + ? this.expr(tag.expression) + : null; + const callee = recv === null ? this.expr(tag) : null; + const strings = this.syntheticArray(e, "template"); + const raw = this.syntheticArray(e.template, "raw"); + this.add(this.element(strings), this.prim("string")); + this.add(this.element(raw), this.prim("string")); + this.add(this.field(strings, "raw"), raw); + const args = [this.constNode(strings)]; + if (ts.isTemplateExpression(e.template)) + for (const span of list(e.template.templateSpans)) args.push(this.expr(span.expression)); + const dst = this.newNode(); + if (recv !== null && ts.isPropertyAccessExpression(tag)) { + const name = tag.name.text; + this.ops.push({ kind: "read", recv, name, node: tag }); + this.on(recv, (a) => { + const fn = this.newNode(); + this.loadAtom(a, name, fn, tag); + this.invoke(fn, this.constNode(a), args, false, dst, e); + }); + } else this.invoke(callee!, null, args, false, dst, e); + return dst; + } + + args(e: ts.CallExpression | ts.NewExpression): { args: number[]; spread: boolean } { + const out: number[] = []; + let spread = false; + for (const a of list(e.arguments)) { + if (ts.isSpreadElement(a)) { + const inner = unparen(a.expression); + if (ts.isIdentifier(inner) && inner.text === "arguments") { + // `f(...arguments)`: forward the enclosing function's parameters. + const fn = this.enclosingNonArrow(); + if (fn !== null && out.length === 0) { + if (fn.argPos === null) fn.argPos = [0, 1, 2, 3, 4, 5].map(() => this.newNode()); + out.push(...fn.argPos); + continue; + } + } + spread = true; + out.push(this.expr(a.expression)); + continue; + } + out.push(this.expr(a)); + } + return { args: out, spread }; + } + + call(e: ts.CallExpression): number { + this.noteSite(e); + const callee = unparen(e.expression); + const dst = this.newNode(); + if (callee.kind === ts.SyntaxKind.SuperKeyword) { + const { args, spread } = this.args(e); + const fn = this.enclosingNonArrow(); + const owner = fn?.ownerClass ?? null; + const base = owner !== null ? this.baseClass(owner) : undefined; + if (fn !== null && base) { + this.on(fn.thisNode, (inst) => { + if (inst.kind === "instance") this.callConstructorChain(base, inst, args, spread, e); + }); + } else if (base === undefined) + for (const a of args) this.sink(a, `super(...) at ${locLabel(e)}`); + return dst; + } + // CommonJS `require("")` of an analyzed module: its exports (the + // module's namespace, which holds `module.exports` as its `exports` + // field and the checker's view of its exports). + if (ts.isIdentifier(callee) && callee.text === "require" && e.arguments.nodes.length === 1) { + const spec = e.arguments.nodes[0]!; + if (ts.isStringLiteral(spec)) { + let target: Node | undefined; + try { + const sym = this.checker.getSymbolAtLocation(spec); + target = sym?.valueDeclaration ?? sym?.declarations[0]; + } catch { + target = undefined; + } + if (target !== undefined && ts.isSourceFile(target) && this.files.includes(target)) { + const ns = this.namespaceNode(target); + this.edge(ns, dst); + this.on(ns, (a) => this.edge(this.field(a, "exports"), dst)); + return dst; + } + } + } + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + const recvExpr = unparen(callee.expression); + const { args, spread } = this.args(e); + if (recvExpr.kind === ts.SyntaxKind.SuperKeyword && ts.isPropertyAccessExpression(callee)) { + // super.m(...): the base class member with the current receiver. + const fn = this.enclosingNonArrow(); + const owner = fn?.ownerClass ?? null; + const base = owner !== null ? this.baseClass(owner) : undefined; + if (fn !== null && base) { + const member = this.lookupMember(base, callee.name.text, isStatic(fn.node)); + if (member?.method !== undefined) { + this.callFunction( + this.functionInfo(member.method, this.ownerOf(member.method), fn.ctx), + fn.thisNode, + args, + spread, + dst, + e, + ); + } + } + return dst; + } + const recv = this.expr(recvExpr); + let name: string | null = null; + if (ts.isPropertyAccessExpression(callee)) name = callee.name.text; + else { + const k = unparen(callee.argumentExpression); + if (ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k)) name = k.text; + else this.expr(callee.argumentExpression); + } + if (name !== null) this.ops.push({ kind: "read", recv, name, node: callee }); + // Per receiver atom: the member it resolves to runs with that atom as `this`. + const member = name; + this.on(recv, (a) => { + const fnNode = this.newNode(); + const self = this.constNode(a); + if (member !== null) this.loadAtom(a, member, fnNode, callee); + else this.computedLoad(self, fnNode, callee); + this.invoke(fnNode, self, args, spread, dst, e); + }); + return dst; + } + const calleeNode = this.expr(callee); + const { args, spread } = this.args(e); + this.invoke(calleeNode, null, args, spread, dst, e); + return dst; + } + + invoke( + callee: number, + recv: number | null, + args: number[], + spread: boolean, + dst: number, + site: ts.CallExpression | ts.TaggedTemplateExpression, + ): void { + const undefNode = recv === null ? this.constNode(this.prim("undefined")) : recv; + this.on(callee, (a) => { + switch (a.kind) { + case "function": + this.callFunction(this.infoOfAtom(a)!, undefNode, args, spread, dst, site); + return; + case "builtin": + this.callBuiltin(a, args, spread, dst, site, recv); + return; + case "class": + return; // calling a class throws + case "prim": + return; + default: + for (const x of args) this.sink(x, `argument of ⊤ call at ${locLabel(site)}`); + if (recv !== null && a.kind === "top") + this.sink(recv, `receiver of ⊤ call at ${locLabel(site)}`); + this.add(dst, this.top); + } + }); + } + + newExpr(e: ts.NewExpression): number { + this.noteSite(e); + const dst = this.newNode(); + const callee = this.expr(e.expression); + const { args, spread } = this.args(e); + this.on(callee, (a) => { + switch (a.kind) { + case "class": + this.construct(a.node!, args, spread, dst, e); + return; + case "function": { + // An ES5 constructor function: one instance atom per function. + const inst = this.atoms.intern(`fninst:${this.nodeKey(a.node!)}`, () => ({ + kind: "object", + label: `new ${a.label}`, + node: a.node, + })); + this.fnOfInstance.set(inst, a); + this.add(dst, inst); + const info = this.infoOfAtom(a); + if (info !== undefined) { + this.activate(info); + this.add(info.thisNode, inst); + this.bindArgs(info, args, spread); + this.edge(info.ret, dst); + this.edgeFor(e, info); + } + return; + } + case "builtin": + this.constructBuiltin(a.path ?? "", args, dst, e); + return; + default: + for (const x of args) this.sink(x, `argument of ⊤ constructor at ${locLabel(e)}`); + this.add(dst, this.top); + } + }); + return dst; + } + + constructBuiltin(path: string, args: number[], dst: number, site: Node): void { + switch (path) { + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": { + const c = this.atoms.intern(`coll:${this.nodeKey(site)}`, () => ({ + kind: "collection", + label: `${path}@${locLabel(site)}`, + node: site, + })); + this.add(dst, c); + if (args[0] !== undefined) this.iterateInto(args[0], this.element(c), site); + return; + } + case "Error": + case "TypeError": + case "RangeError": + case "SyntaxError": + this.add(dst, this.builtin("Error.instance")); + return; + case "Promise": { + const p = this.promiseAtom(site, locLabel(site)); + this.add(this.field(p, "value"), this.top); + this.add(dst, p); + if (args[0] !== undefined) { + const resolver = this.constNode(this.builtin("promise.resolver")); + this.on(args[0], (f) => { + if (f.kind === "function") + this.callFunction( + this.infoOfAtom(f)!, + null, + [resolver, resolver], + false, + this.newNode(), + site, + ); + }); + } + return; + } + case "Date": + this.add(dst, this.builtin("Date.instance")); + return; + case "RegExp": + this.add(dst, this.builtin("RegExp.instance")); + return; + case "Array": { + this.add(dst, this.syntheticArray(site, "Array")); + return; + } + case "Proxy": + // The handler's traps run on every operation with the target. + if (args[0] !== undefined) + this.ops.push({ kind: "proxy", recv: args[0], name: null, node: site }); + for (const x of args) this.sink(x, `argument of new Proxy at ${locLabel(site)}`); + this.add(dst, this.top); + return; + default: + for (const x of args) this.sink(x, `argument of new ${path} at ${locLabel(site)}`); + this.add(dst, this.top); + } + } + + /** Builtin call summaries; anything unmodeled makes its arguments escape. */ + callBuiltin( + a: Atom, + args: number[], + spread: boolean, + dst: number, + site: ts.CallExpression | ts.TaggedTemplateExpression, + recvNode: number | null, + ): void { + const path = a.path ?? ""; + const recv = a.receiver; + const arg = (i: number): number => args[i] ?? this.constNode(this.prim("undefined")); + const prim = (p: string): void => this.add(dst, this.prim(p)); + const callback = (i: number, cbArgs: number[], ret: number | null): void => { + if (args[i] === undefined) return; + this.on(args[i]!, (f) => { + if (f.kind === "function") { + const saved = this.forceLoop; + this.forceLoop = LOOP_CALLBACK_METHODS.has(path.slice(path.indexOf(".") + 1)); + this.callFunction(this.infoOfAtom(f)!, null, cbArgs, false, ret ?? this.newNode(), site); + this.forceLoop = saved; + } else if (f.kind === "top" || f.kind === "builtin") { + for (const x of cbArgs) this.sink(x, `argument of ⊤ callback at ${locLabel(site)}`); + if (ret !== null) this.add(ret, this.top); + } + }); + }; + const escapeAll = (): void => { + for (const x of args) this.sink(x, `argument of ${path} at ${locLabel(site)}`); + this.add(dst, this.top); + }; + // Methods of builtin receivers. + if (recv !== undefined) { + const method = path.slice(path.indexOf(".") + 1); + if (path.startsWith("array.")) { + const el = this.element(recv); + const num = this.constNode(this.prim("number")); + const recvN = this.constNode(recv); + switch (method) { + case "push": + case "unshift": + for (const x of args) { + if (spread) this.iterateInto(x, el, site); + else this.edge(x, el); + } + prim("number"); + return; + case "pop": + case "shift": + case "at": + this.edge(el, dst); + this.add(dst, this.prim("undefined")); + return; + case "find": + case "findLast": + callback(0, [el, num, recvN], null); + this.edge(el, dst); + this.add(dst, this.prim("undefined")); + return; + case "forEach": + callback(0, [el, num, recvN], null); + prim("undefined"); + return; + case "some": + case "every": + case "includes": + callback(0, [el, num, recvN], null); + prim("boolean"); + return; + case "findIndex": + case "findLastIndex": + case "indexOf": + case "lastIndexOf": + callback(0, [el, num, recvN], null); + prim("number"); + return; + case "join": + case "toString": + prim("string"); + return; + case "map": + case "flatMap": { + const out = this.syntheticArray(site, method); + const outEl = this.element(out); + if (method === "map") callback(0, [el, num, recvN], outEl); + else { + const r = this.newNode(); + callback(0, [el, num, recvN], r); + this.iterateInto(r, outEl, site); + } + this.add(dst, out); + return; + } + case "filter": + case "slice": + case "concat": + case "reverse": + case "sort": + case "splice": + case "toSorted": + case "toReversed": + case "flat": { + if (method === "filter") callback(0, [el, num, recvN], null); + if (method === "sort" || method === "toSorted") callback(0, [el, el], null); + if (method === "reverse" || method === "sort") { + this.add(dst, recv); + return; + } + const out = this.syntheticArray(site, method); + this.edge(el, this.element(out)); + if (method === "concat") + for (const x of args) this.iterateInto(x, this.element(out), site); + if (method === "splice") for (const x of args.slice(2)) this.edge(x, el); + this.add(dst, out); + return; + } + case "reduce": + case "reduceRight": { + const acc = this.newNode(); + if (args[1] !== undefined) this.edge(args[1], acc); + else this.edge(el, acc); + callback(0, [acc, el, num, recvN], acc); + this.edge(acc, dst); + return; + } + case "keys": + case "values": + case "entries": { + const out = this.syntheticArray(site, method); + if (method === "values") this.edge(el, this.element(out)); + else if (method === "keys") this.add(this.element(out), this.prim("number")); + else { + const pair = this.syntheticArray(site, "entry"); + this.add(this.element(pair), this.prim("number")); + this.edge(el, this.element(pair)); + this.add(this.element(out), pair); + } + this.add(dst, out); + return; + } + case "fill": + for (const x of args) this.edge(x, el); + this.add(dst, recv); + return; + } + escapeAll(); + this.escape(recv, `array.${method} at ${locLabel(site)}`); + return; + } + if (path.startsWith("string.") || path.startsWith("number.") || path.startsWith("boolean.")) { + const r = STRING_METHOD_RESULTS.get(method); + if (r === "array") { + const out = this.syntheticArray(site, method); + this.add(this.element(out), this.prim("string")); + this.add(this.element(out), this.prim("undefined")); + this.add(dst, out); + this.add(dst, this.prim("null")); + } else if (r !== undefined) prim(r); + else if (method === "replace" || method === "replaceAll") prim("string"); + else escapeAll(); + if (method === "replace" || method === "replaceAll") + callback(1, [this.constNode(this.prim("string"))], null); + return; + } + if (path.startsWith("regexp.")) { + if (method === "test") prim("boolean"); + else if (method === "exec") { + const out = this.syntheticArray(site, "exec"); + this.add(this.element(out), this.prim("string")); + this.add(dst, out); + prim("null"); + } else escapeAll(); + return; + } + if (path.startsWith("promise.")) { + const out = this.promiseAtom(site, locLabel(site)); + const value = this.field(recv, "value"); + const ret = this.newNode(); + if (method === "then") { + callback(0, [value], ret); + callback(1, [this.constNode(this.top)], ret); + } else if (method === "catch") { + callback(0, [this.constNode(this.top)], ret); + this.edge(value, ret); + } else { + callback(0, [], null); + this.edge(value, ret); + } + this.awaitInto(ret, this.field(out, "value")); + this.add(dst, out); + return; + } + if (path.startsWith("collection.")) { + const el = this.element(recv); + switch (method) { + case "add": + case "set": + for (const x of args) this.edge(x, el); + this.add(dst, recv); + return; + case "get": + this.edge(el, dst); + prim("undefined"); + return; + case "has": + case "delete": + prim("boolean"); + return; + case "clear": + prim("undefined"); + return; + case "forEach": + callback(0, [el, el], null); + prim("undefined"); + return; + case "keys": + case "values": + case "entries": { + const out = this.syntheticArray(site, method); + this.edge(el, this.element(out)); + if (method === "entries") { + const pair = this.syntheticArray(site, "entry"); + this.edge(el, this.element(pair)); + this.nodes[this.element(out)]!.atoms.clear(); + this.add(this.element(out), pair); + } + this.add(dst, out); + return; + } + } + escapeAll(); + return; + } + if (path.startsWith("function.")) { + if (recv.kind !== "function" || recv.node === undefined) { + escapeAll(); + return; + } + if (method === "bind") { + const info = this.infoOfAtom(recv); + if (info !== undefined && args[0] !== undefined && !info.isArrow) + this.edge(args[0], info.thisNode); + if (info !== undefined && args.length > 1) { + this.activate(info); + for (const p of info.params) this.add(p, this.top); + for (const x of args.slice(1)) this.sink(x, `bound argument at ${locLabel(site)}`); + } + this.add(dst, recv); + return; + } + if (method === "call") { + this.callFunction( + this.infoOfAtom(recv)!, + args[0] ?? this.constNode(this.prim("undefined")), + args.slice(1), + spread, + dst, + site, + ); + return; + } + // apply: arguments from an array + const info = this.infoOfAtom(recv); + if (info !== undefined) { + this.activate(info); + if (args[0] !== undefined && !info.isArrow) this.edge(args[0], info.thisNode); + for (const p of info.params) + if (args[1] !== undefined) this.iterateInto(args[1], p, site); + this.edge(info.ret, dst); + this.edgeFor(site, info); + } + return; + } + if (path.startsWith("object.")) { + if (method === "hasOwnProperty") prim("boolean"); + else if (method === "toString") prim("string"); + else escapeAll(); + return; + } + escapeAll(); + return; + } + // `Object.prototype.m.call(o, …)`-style calls of builtin prototype methods. + if (/^(Object|Array|String|Function)\.prototype\.\w+\.(call|apply)$/.test(path)) { + const m = path.split(".")[2]!; + if (m === "hasOwnProperty" || m === "propertyIsEnumerable" || m === "isPrototypeOf") + prim("boolean"); + else if (m === "toString") prim("string"); + else escapeAll(); + return; + } + switch (path) { + case "Object.keys": + case "Object.getOwnPropertyNames": { + const out = this.syntheticArray(site, "keys"); + this.add(this.element(out), this.prim("string")); + this.add(dst, out); + return; + } + case "Object.values": + case "Object.entries": { + const out = this.syntheticArray(site, path === "Object.values" ? "values" : "entries"); + const vals = this.newNode(); + if (args[0] !== undefined) this.computedLoad(args[0], vals, site); + if (path === "Object.values") this.edge(vals, this.element(out)); + else { + const pair = this.syntheticArray(site, "entry"); + this.add(this.element(pair), this.prim("string")); + this.edge(vals, this.element(pair)); + this.add(this.element(out), pair); + } + this.add(dst, out); + return; + } + case "Object.freeze": + case "Object.seal": + case "Object.preventExtensions": + this.ops.push({ + kind: path === "Object.freeze" ? "freeze" : "seal", + recv: arg(0), + name: null, + node: site, + }); + this.edge(arg(0), dst); + return; + case "Object.isFrozen": + case "Object.isSealed": + case "Object.isExtensible": + case "Array.isArray": + case "Number.isInteger": + case "Number.isNaN": + case "Number.isFinite": + case "Number.isSafeInteger": + case "isNaN": + case "isFinite": + prim("boolean"); + return; + case "Object.assign": { + this.ops.push({ kind: "assign", recv: arg(0), name: null, node: site }); + this.on(arg(0), (t) => { + for (const s of args.slice(1)) this.copyFields(s, t, site); + if (t.kind === "top") + for (const s of args.slice(1)) + this.sink(s, `Object.assign into ⊤ at ${locLabel(site)}`); + }); + this.edge(arg(0), dst); + return; + } + case "Object.defineProperty": + case "Object.defineProperties": + case "Reflect.defineProperty": + this.ops.push({ kind: "defineProperty", recv: arg(0), name: null, node: site }); + this.sink(arg(2), `property descriptor at ${locLabel(site)}`); + this.sink(arg(1), `property descriptor at ${locLabel(site)}`); + this.edge(arg(0), dst); + return; + case "Object.setPrototypeOf": + case "Reflect.setPrototypeOf": + this.ops.push({ kind: "setPrototypeOf", recv: arg(0), name: null, node: site }); + this.sink(arg(1), `prototype at ${locLabel(site)}`); + this.edge(arg(0), dst); + return; + case "Object.create": { + const d = this.atoms.intern(`dict:${this.nodeKey(site)}`, () => ({ + kind: "dictionary", + label: `Object.create@${locLabel(site)}`, + node: site, + })); + this.add(dst, d); + return; + } + case "Object.fromEntries": { + const d = this.atoms.intern(`dict:${this.nodeKey(site)}`, () => ({ + kind: "dictionary", + label: `fromEntries@${locLabel(site)}`, + node: site, + })); + const pairs = this.newNode(); + if (args[0] !== undefined) this.iterateInto(args[0], pairs, site); + const vals = this.newNode(); + this.iterateInto(pairs, vals, site); + this.edge(vals, this.anyField(d)); + this.add(dst, d); + return; + } + case "Object.getPrototypeOf": + case "Reflect.getPrototypeOf": + this.add(dst, this.top); + return; + case "Object.is": + prim("boolean"); + return; + case "Array.from": + case "Array.of": { + const out = this.syntheticArray(site, path); + if (path === "Array.of") for (const x of args) this.edge(x, this.element(out)); + else if (args[1] !== undefined) { + const els = this.newNode(); + if (args[0] !== undefined) this.iterateInto(args[0], els, site); + callback(1, [els, this.constNode(this.prim("number"))], this.element(out)); + } else if (args[0] !== undefined) this.iterateInto(args[0], this.element(out), site); + this.add(dst, out); + return; + } + case "JSON.parse": { + // A reviver runs on every parsed value with its key, the holder as `this`, innermost first: + // what it returns replaces the value, in its holder (which later calls receive) and in the + // result. + const revived = this.newNode(); + this.add(revived, this.top); + this.repeatingSites.add(site); + if (args[1] !== undefined) + this.on(args[1], (f) => { + if (f.kind === "function") + this.callFunction( + this.infoOfAtom(f)!, + revived, + [this.constNode(this.prim("string")), revived], + false, + revived, + site, + ); + else if (f.kind === "top") + this.sink(revived, `JSON.parse reviver at ${locLabel(site)}`); + }); + this.edge(revived, dst); + return; + } + case "JSON.stringify": { + // Every value it serializes: toJSON methods run on them, and a + // replacer function with each key and value, the holder as `this`. + // Their results are serialized in turn. + const serialized = this.newNode(); + this.childrenInto(serialized); + this.repeatingSites.add(site); + this.edge(arg(0), serialized); + const key = this.constNode(this.prim("string")); + this.on(serialized, (v) => this.callToJSON(v, key, serialized, site)); + if (args[1] !== undefined) + this.on(args[1], (f) => { + if (f.kind === "function") + this.callFunction( + this.infoOfAtom(f)!, + serialized, + [key, serialized], + false, + serialized, + site, + ); + else if (f.kind === "top") + this.sink(serialized, `JSON.stringify replacer at ${locLabel(site)}`); + }); + prim("string"); + prim("undefined"); + return; + } + case "Reflect.deleteProperty": { + const key = ts.isCallExpression(site) ? unparen(list(site.arguments)[1]!) : undefined; + const name = + key !== undefined && (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) + ? key.text + : null; + this.ops.push({ kind: "delete", recv: arg(0), name, node: site }); + prim("boolean"); + return; + } + case "Proxy.revocable": + this.ops.push({ kind: "proxy", recv: arg(0), name: null, node: site }); + escapeAll(); + return; + case "Reflect.construct": { + // Reflect.construct(Proxy, [target, handler]) is `new Proxy(target, handler)`: the arguments + // are the list's elements. + const listed = this.newNode(); + if (args[1] !== undefined) this.iterateInto(args[1], listed, site); + this.on(arg(0), (c) => { + if (c.kind === "builtin" && c.path === "Proxy") + this.ops.push({ kind: "proxy", recv: listed, name: null, node: site }); + }); + this.sink(listed, `argument of ${path} at ${locLabel(site)}`); + escapeAll(); + return; + } + case "String": + case "encodeURIComponent": + case "decodeURIComponent": + case "encodeURI": + case "decodeURI": + prim("string"); + return; + case "Number": + case "parseInt": + case "parseFloat": + case "Number.parseInt": + case "Number.parseFloat": + prim("number"); + return; + case "Boolean": + prim("boolean"); + return; + case "Symbol": + case "Symbol.for": + prim("symbol"); + return; + case "BigInt": + prim("bigint"); + return; + case "Promise.resolve": { + const p = this.promiseAtom(site, locLabel(site)); + if (args[0] !== undefined) this.awaitInto(args[0], this.field(p, "value")); + else this.add(this.field(p, "value"), this.prim("undefined")); + this.add(dst, p); + return; + } + case "Promise.reject": { + const p = this.promiseAtom(site, locLabel(site)); + this.add(dst, p); + if (args[0] !== undefined) this.sink(args[0], `rejection at ${locLabel(site)}`); + return; + } + case "Promise.all": + case "Promise.allSettled": + case "Promise.race": + case "Promise.any": { + const p = this.promiseAtom(site, locLabel(site)); + const items = this.newNode(); + if (args[0] !== undefined) this.iterateInto(args[0], items, site); + if (path === "Promise.all") { + const out = this.syntheticArray(site, "all"); + this.awaitInto(items, this.element(out)); + this.add(this.field(p, "value"), out); + } else if (path === "Promise.race" || path === "Promise.any") + this.awaitInto(items, this.field(p, "value")); + else this.add(this.field(p, "value"), this.top); + this.add(dst, p); + return; + } + case "promise.resolver": + for (const x of args) this.sink(x, `promise resolution at ${locLabel(site)}`); + return; + } + if (path.startsWith("console.")) { + prim("undefined"); + return; + } + if (path.startsWith("Math.")) { + prim("number"); + return; + } + if (path === "Date.now") { + prim("number"); + return; + } + if (path.startsWith("Date.instance") || path === "Error.instance") { + escapeAll(); + return; + } + void recvNode; + escapeAll(); + } + + /** Re-run deferred field copies until nothing new flows. */ + finish(): void { + for (;;) { + const before = this.steps; + for (const c of this.lateCopies) c(); + this.solve(); + if (this.steps === before || this.budgetExceeded) return; + } + } +} + +/** The export name of a CommonJS export declaration the checker resolves + * bindings to: a member of the `module.exports` literal, or the target of + * `exports.name =` / `module.exports.name =`. */ +function cjsExportName(decl: Node): string | null { + if (ts.isShorthandPropertyAssignment(decl) || ts.isPropertyAssignment(decl)) { + const n = decl.name; + return ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : null; + } + if (ts.isPropertyAccessExpression(decl) && ts.isIdentifier(decl.name)) return decl.name.text; + if ( + ts.isBinaryExpression(decl) && + ts.isPropertyAccessExpression(decl.left) && + ts.isIdentifier(decl.left.name) + ) + return decl.left.name.text; + return null; +} diff --git a/packages/compiler/src/frontend/shape-inference/constant-graphs.ts b/packages/compiler/src/frontend/shape-inference/constant-graphs.ts new file mode 100644 index 000000000..dffa8d17d --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/constant-graphs.ts @@ -0,0 +1,421 @@ +/* Static-object specialization, stage 1 (DESIGN-constant-objects.md): + * constant object graphs. + * + * An object literal is a SINGLETON when the code that creates it runs at + * most once: at module top level, or in a function every call path to which + * starts at module evaluation through exactly one call site outside loops + * (IIFEs, revealing modules, init helpers). A singleton's field is CONSTANT + * when every store to it runs in such once-only code, no reflective or + * computed write reaches the object, and the object never escapes to code + * the analysis cannot see. A constant object has only constant fields and a + * fixed key set; constant objects whose constant fields point at each other + * form a constant graph (`ns`, `ns.util`, `ns.util.trim`). + * + * This stage computes the graphs and reports them, with the rule that + * demoted every near miss, and the reads and calls a later stage could fold. + * Nothing reaches code generation. */ +import * as ts from "../ts7/adapter.js"; +import type { Atom } from "./domain.js"; +import type { FunctionInfo, Op, ShapeAnalysis } from "./analyze.js"; +import { npmStaticPackageOfPath } from "../npm-static.js"; + +type Node = ts.Node; + +export interface ConstantField { + readonly key: string; + /** Distinct atoms the field may hold. */ + readonly values: readonly Atom[]; + /** Stores (the literal's own member included). */ + readonly stores: number; +} + +/** An object the analysis tracks as one allocation: an object literal, a + * function object (a namespace or an ES5 constructor), or a constructor's + * default prototype object. */ +export interface Candidate { + readonly atom: Atom; + /** The syntax that creates it (the literal or the function). */ + readonly node: Node; + readonly kind: "literal" | "function" | "prototype"; + /** Keys the creating syntax itself defines. */ + readonly keys: readonly string[]; + /** Whether the creating syntax is a plain data literal. */ + readonly plain: boolean; + readonly inPackage: boolean; +} + +export interface ConstantObject { + readonly site: Candidate; + readonly fields: readonly ConstantField[]; + readonly frozen: boolean; +} + +export interface NearMiss { + readonly site: Candidate; + /** Null when the whole object was demoted. */ + readonly key: string | null; + readonly rule: + | "init-factory" + | "allocated-repeatedly" + | "escapes" + | "reflective-write" + | "computed-write" + | "literal-form" + | "written-after-init" + | "lazy-cache"; + readonly detail: string; +} + +export interface ConstantGraphs { + readonly objects: readonly ConstantObject[]; + readonly nearMisses: readonly NearMiss[]; + /** Reads `e.k` whose receiver is exactly one constant object and whose + * field is constant, and the subset that are callees. */ + readonly foldableReads: number; + readonly foldableCalls: number; + /** Reads of a constant field through a receiver that may also be other + * values. */ + readonly mixedReads: number; + readonly once: (fn: Node) => boolean; +} + +function isLoop(n: Node): boolean { + return ( + ts.isForStatement(n) || + ts.isForInStatement(n) || + ts.isForOfStatement(n) || + ts.isWhileStatement(n) || + ts.isDoStatement(n) + ); +} + +/** The function (or source file) whose body contains `n`. */ +function ownerFunction(n: Node): Node { + for (let p = n.parent; p !== undefined; p = p.parent) { + if (ts.isSourceFile(p)) return p; + if (ts.isFunctionLike(p) && (p as { body?: Node }).body !== undefined) return p; + } + return n.getSourceFile(); +} + +/** Whether `n` sits in a loop of its own function's body. */ +function inLoop(n: Node, owner: Node): boolean { + for (let p = n.parent; p !== undefined && p !== owner; p = p.parent) if (isLoop(p)) return true; + return false; +} + +function loc(n: Node): string { + const sf = n.getSourceFile(); + const file = sf.fileName.split("/node_modules/").pop() ?? sf.fileName; + return `${file.split("/").slice(-2).join("/")}:${sf.getLineAndCharacterOfPosition(ts.tokenStartOfNode(n)).line + 1}`; +} + +/** `x.k || (x.k = v)`, `x.k ??= v`, `x.k ||= v`, `if (!x.k) x.k = v`. */ +function isCacheStore(write: Node): boolean { + let assign: Node | undefined = write.parent; + while (assign !== undefined && ts.isParenthesizedExpression(assign)) assign = assign.parent; + if (assign === undefined || !ts.isBinaryExpression(assign)) return false; + const op = assign.operatorToken.kind; + if (op === ts.SyntaxKind.BarBarEqualsToken || op === ts.SyntaxKind.QuestionQuestionEqualsToken) + return true; + if (op !== ts.SyntaxKind.EqualsToken) return false; + const text = write.getText(); + let up: Node | undefined = assign.parent; + while (up !== undefined && ts.isParenthesizedExpression(up)) up = up.parent; + if ( + up !== undefined && + ts.isBinaryExpression(up) && + (up.operatorToken.kind === ts.SyntaxKind.BarBarToken || + up.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) && + up.left.getText() === text + ) + return true; + for (let p: Node | undefined = assign.parent; p !== undefined; p = p.parent) { + if (ts.isIfStatement(p)) { + const cond = p.expression.getText().replace(/\s+/g, ""); + return cond === `!${text}` || cond === `${text}===undefined` || cond === `${text}==null`; + } + if (ts.isFunctionLike(p) || ts.isSourceFile(p)) break; + } + return false; +} + +const OBJECT_DEMOTING: ReadonlySet = new Set([ + "computedWrite", + "delete", + "defineProperty", + "setPrototypeOf", + "assign", + "proxy", +]); + +export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs { + const infosByNode = new Map(); + for (const info of analysis.functions.values()) { + let l = infosByNode.get(info.node); + if (l === undefined) infosByNode.set(info.node, (l = [])); + l.push(info); + } + const edgesTo = new Map(); + for (const e of analysis.calls) { + let l = edgesTo.get(e.callee.node); + if (l === undefined) edgesTo.set(e.callee.node, (l = [])); + l.push(e); + } + const escapedNodes = new Set([...analysis.escapedFunctions].map((f) => f.node)); + + const onceMemo = new Map(); + const once = (fn: Node): boolean => { + if (ts.isSourceFile(fn)) return true; + const known = onceMemo.get(fn); + if (known !== undefined) return known; + onceMemo.set(fn, false); // a cycle is not once-only + let result = false; + const edges = edgesTo.get(fn) ?? []; + if ( + infosByNode.has(fn) && + !escapedNodes.has(fn) && + edges.length > 0 && + new Set(edges.map((e) => e.site)).size === 1 + ) { + result = edges.every( + (e) => + !e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && once(e.caller.node), + ); + } + onceMemo.set(fn, result); + return result; + }; + + // Whether every call of a function comes from once-only code outside + // loops, through any number of call sites: an init factory, whose + // allocations are one object per call site (schema builders called at + // module initialization). + const initOnlyMemo = new Map(); + const initOnly = (fn: Node): boolean => { + if (ts.isSourceFile(fn)) return true; + const known = initOnlyMemo.get(fn); + if (known !== undefined) return known; + initOnlyMemo.set(fn, false); + const edges = edgesTo.get(fn) ?? []; + const result = + infosByNode.has(fn) && + !escapedNodes.has(fn) && + edges.length > 0 && + edges.every( + (e) => + !e.inLoop && + e.caller !== null && + !inLoop(e.site, e.caller.node) && + (once(e.caller.node) || initOnly(e.caller.node)), + ); + initOnlyMemo.set(fn, result); + return result; + }; + + const opsByAtom = new Map(); + for (const op of analysis.ops) { + for (const a of analysis.nodes[op.recv]!.atoms) { + let l = opsByAtom.get(a); + if (l === undefined) opsByAtom.set(a, (l = [])); + l.push(op); + } + } + + const objects: ConstantObject[] = []; + const nearMisses: NearMiss[] = []; + const constantKeys = new Map>(); + const candidates: Candidate[] = []; + for (const site of analysis.sites) { + if (site.kind !== "object") continue; + candidates.push({ + atom: site.atom, + node: site.node, + kind: "literal", + keys: site.keys ?? [], + plain: !( + site.hasAccessor || + site.hasComputedKey || + site.spreads.length > 0 || + site.keys === null + ), + inPackage: site.inPackage, + }); + } + const inPackage = (n: Node): boolean => + npmStaticPackageOfPath(n.getSourceFile().fileName) !== null; + for (const atom of analysis.atoms.atoms) { + if (atom.kind !== "function" || atom.node === undefined) continue; + const hasFields = (analysis.fields.get(atom)?.size ?? 0) > 0; + const proto = analysis.protoOf.get(atom); + if (!hasFields && proto === undefined) continue; + candidates.push({ + atom, + node: atom.node, + kind: "function", + keys: [], + plain: true, + inPackage: inPackage(atom.node), + }); + if (proto !== undefined) + candidates.push({ + atom: proto, + node: atom.node, + kind: "prototype", + keys: ["constructor"], + plain: true, + inPackage: inPackage(atom.node), + }); + } + for (const site of candidates) { + // A function object is created where its expression or declaration + // runs; its prototype with it. + const owner = ownerFunction(site.node); + if (!once(owner) || inLoop(site.node, owner)) { + const factory = !inLoop(site.node, owner) && initOnly(owner); + if (site.inPackage || factory) + nearMisses.push({ + site, + key: null, + rule: factory ? "init-factory" : "allocated-repeatedly", + detail: factory + ? `one object per call of ${loc(owner)} (${new Set((edgesTo.get(owner) ?? []).map((e) => e.site)).size} call sites)` + : `created in ${owner.kind === ts.SyntaxKind.SourceFile ? "a module loop" : loc(owner)}`, + }); + continue; + } + const escaped = analysis.escaped.get(site.atom); + if (escaped !== undefined) { + nearMisses.push({ site, key: null, rule: "escapes", detail: escaped }); + continue; + } + if (!site.plain) { + nearMisses.push({ + site, + key: null, + rule: "literal-form", + detail: "accessor, computed key or spread in the literal", + }); + continue; + } + const ops = opsByAtom.get(site.atom) ?? []; + const demoting = ops.find((op) => OBJECT_DEMOTING.has(op.kind)); + if (demoting !== undefined) { + nearMisses.push({ + site, + key: null, + rule: demoting.kind === "computedWrite" ? "computed-write" : "reflective-write", + detail: `${demoting.kind} at ${loc(demoting.node)}`, + }); + continue; + } + const frozen = ops.some((op) => op.kind === "freeze"); + const stores = new Map(); + for (const k of site.keys) stores.set(k, 1); + // A function's own data fields come only from stores. + const demotedKeys = new Map(); + for (const op of ops) { + // A `length` store on an object (not an array) is an ordinary write. + if ((op.kind !== "write" && op.kind !== "lengthWrite") || op.name === null) continue; + stores.set(op.name, (stores.get(op.name) ?? 0) + 1); + const fn = ownerFunction(op.node); + if (once(fn) && !inLoop(op.node, fn)) continue; + if (demotedKeys.has(op.name)) continue; + demotedKeys.set(op.name, { + site, + key: op.name, + rule: isCacheStore(op.node) ? "lazy-cache" : "written-after-init", + detail: loc(op.node), + }); + } + for (const miss of demotedKeys.values()) nearMisses.push(miss); + const fieldMap = analysis.fields.get(site.atom) ?? new Map(); + const fields: ConstantField[] = []; + const keys = new Set(); + for (const [key, count] of stores) { + if (demotedKeys.has(key)) continue; + const id = fieldMap.get(key); + const values = id === undefined ? [] : [...analysis.nodes[id]!.atoms]; + fields.push({ key, values, stores: count }); + keys.add(key); + } + constantKeys.set(site.atom, keys); + if (demotedKeys.size === 0) objects.push({ site, fields, frozen }); + } + + let foldableReads = 0, + foldableCalls = 0, + mixedReads = 0; + for (const op of analysis.ops) { + if (op.kind !== "read" || op.name === null) continue; + const atoms = analysis.nodes[op.recv]!.atoms; + const constant = [...atoms].filter((a) => constantKeys.get(a)?.has(op.name!) === true); + if (constant.length === 0) continue; + if (atoms.size === 1) { + foldableReads++; + const p = op.node.parent; + if (p !== undefined && ts.isCallExpression(p) && p.expression === op.node) foldableCalls++; + } else mixedReads++; + } + return { objects, nearMisses, foldableReads, foldableCalls, mixedReads, once }; +} + +/** Report lines (SCRIPTC_DEBUG_SHAPES=const): per package, the constant + * graphs with their roots, the near misses by rule, and the foldable reads. */ +export function constantGraphReport(analysis: ShapeAnalysis, graphs: ConstantGraphs): string[] { + const lines: string[] = []; + const pkgOf = (site: Candidate): string => + npmStaticPackageOfPath(site.node.getSourceFile().fileName) ?? ""; + const byPkg = new Map(); + const entry = (pkg: string) => { + let e = byPkg.get(pkg); + if (e === undefined) byPkg.set(pkg, (e = { objects: [], misses: [] })); + return e; + }; + for (const o of graphs.objects) entry(pkgOf(o.site)).objects.push(o); + for (const m of graphs.nearMisses) entry(pkgOf(m.site)).misses.push(m); + const constantAtoms = new Set(graphs.objects.map((o) => o.site.atom)); + const referenced = new Set(); + for (const o of graphs.objects) + for (const f of o.fields) + if (f.values.length === 1 && constantAtoms.has(f.values[0]!)) referenced.add(f.values[0]!); + const describe = (o: ConstantObject, depth: number, seen: Set): string => { + const parts = o.fields.map((f) => { + if (f.values.length !== 1) + return `${f.key}: ${f.values.length === 0 ? "?" : f.values.map((a) => a.label).join("|")}`; + const v = f.values[0]!; + const child = graphs.objects.find((x) => x.site.atom === v); + if (child !== undefined && depth < 3 && !seen.has(v)) { + seen.add(v); + return `${f.key}: ${describe(child, depth + 1, seen)}`; + } + return `${f.key}: ${v.kind === "function" && child === undefined ? "fn" : v.label}`; + }); + const shown = parts.length > 8 ? [...parts.slice(0, 8), `… ${parts.length - 8} more`] : parts; + return `{${shown.join(", ")}}${o.frozen ? " (frozen)" : ""}`; + }; + lines.push( + `analysis ${analysis.steps} steps${analysis.budgetExceeded ? " (BUDGET EXCEEDED: facts incomplete)" : ""}, ${analysis.files.length} files`, + ); + lines.push( + `constant objects ${graphs.objects.length}, near misses ${graphs.nearMisses.length}; foldable reads ${graphs.foldableReads} (calls ${graphs.foldableCalls}), mixed-receiver reads ${graphs.mixedReads}`, + ); + for (const [pkg, e] of [...byPkg].sort((a, b) => a[0].localeCompare(b[0]))) { + const fields = e.objects.reduce((n, o) => n + o.fields.length, 0); + const rules = new Map(); + for (const m of e.misses) rules.set(m.rule, (rules.get(m.rule) ?? 0) + 1); + lines.push( + `${pkg}: ${e.objects.length} constant objects (${fields} fields); near misses ${[...rules].map(([r, n]) => `${r} ${n}`).join(", ") || "none"}`, + ); + for (const o of e.objects) { + if (referenced.has(o.site.atom)) continue; + lines.push(` graph ${loc(o.site.node)} ${describe(o, 0, new Set([o.site.atom]))}`); + } + for (const m of e.misses.filter((x) => x.rule !== "allocated-repeatedly").slice(0, 40)) { + lines.push( + ` near miss ${loc(m.site.node)}${m.key === null ? "" : ` .${m.key}`}: ${m.rule} (${m.detail})`, + ); + } + } + return lines; +} diff --git a/packages/compiler/src/frontend/shape-inference/domain.ts b/packages/compiler/src/frontend/shape-inference/domain.ts new file mode 100644 index 000000000..d90a83cf2 --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/domain.ts @@ -0,0 +1,154 @@ +/* Shape inference (results/design-shape-inference/DESIGN.md), stage S1: + * the abstract domain of the allocation-site points-to analysis. + * + * An abstract value is a set of atoms. An atom names a set of runtime + * values: a primitive kind, an allocation site (object or array literal, or + * a value a builtin creates at one call site), the instances of one class, + * one class value, one function (closure site), a builtin object, or ⊤ — + * every value the analysis does not track. Value nodes hold atom sets and + * flow into each other along inclusion edges; the solver (analyze.ts) + * attaches load, store and call constraints that fire as atoms arrive. */ +import type * as ts from "../ts7/adapter.js"; + +export type AtomKind = + | "prim" + | "top" + | "object" + | "array" + | "instance" + | "class" + | "function" + | "builtin" + | "promise" + | "collection" + | "dictionary"; + +export interface Atom { + readonly id: number; + readonly kind: AtomKind; + /** Short display name, e.g. `string`, `ZodString`, `{status,value}@types.js:804`. */ + readonly label: string; + /** The syntax that creates the atom: the literal, class or function + * declaration, or the builtin call. Undefined for prims, ⊤ and builtins. */ + readonly node?: ts.Node | undefined; + /** builtin atoms: the dotted path (`Object.keys`, `array.push`). */ + readonly path?: string | undefined; + /** method atoms of builtin receivers: the receiver atom. */ + readonly receiver?: Atom | undefined; + /** function atoms: the analysis context of the function's clone (a + * method's receiver class), "" for the context-insensitive default. */ + readonly ctx?: string | undefined; +} + +export class AtomTable { + readonly atoms: Atom[] = []; + private readonly byKey = new Map(); + + intern(key: string, make: (id: number) => Omit): Atom { + let atom = this.byKey.get(key); + if (atom === undefined) { + const id = this.atoms.length; + atom = { id, ...make(id) }; + this.atoms.push(atom); + this.byKey.set(key, atom); + } + return atom; + } +} + +/** Object.prototype members: a read of one of these names on a plain + * object resolves on the prototype, never to an absent own key. */ +export const OBJECT_PROTOTYPE_MEMBERS: ReadonlySet = new Set([ + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "__proto__", +]); + +/** Global names the analysis models as builtin objects. Any other global is ⊤. */ +export const MODELED_GLOBALS: ReadonlySet = new Set([ + "Object", + "Array", + "JSON", + "Math", + "Number", + "String", + "Boolean", + "Symbol", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Date", + "RegExp", + "console", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "BigInt", + "Proxy", +]); + +/** Results of string (and number) methods, by name. Anything else is ⊤. */ +type MethodResult = "string" | "number" | "boolean" | "array"; +export const STRING_METHOD_RESULTS: ReadonlyMap = new Map< + string, + MethodResult +>([ + ["at", "string"], + ["charAt", "string"], + ["charCodeAt", "number"], + ["codePointAt", "number"], + ["concat", "string"], + ["endsWith", "boolean"], + ["includes", "boolean"], + ["indexOf", "number"], + ["lastIndexOf", "number"], + ["localeCompare", "number"], + ["match", "array"], + ["matchAll", "array"], + ["normalize", "string"], + ["padEnd", "string"], + ["padStart", "string"], + ["repeat", "string"], + ["replace", "string"], + ["replaceAll", "string"], + ["search", "number"], + ["slice", "string"], + ["split", "array"], + ["startsWith", "boolean"], + ["substring", "string"], + ["substr", "string"], + ["toLowerCase", "string"], + ["toUpperCase", "string"], + ["toLocaleLowerCase", "string"], + ["toLocaleUpperCase", "string"], + ["toString", "string"], + ["trim", "string"], + ["trimEnd", "string"], + ["trimStart", "string"], + ["valueOf", "string"], + ["test", "boolean"], + ["toFixed", "string"], + ["toPrecision", "string"], + ["toExponential", "string"], +]); diff --git a/packages/compiler/src/frontend/shape-inference/index.ts b/packages/compiler/src/frontend/shape-inference/index.ts new file mode 100644 index 000000000..402a788b1 --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/index.ts @@ -0,0 +1,24 @@ +/* Shape inference for untyped npm JavaScript + * (results/design-shape-inference/DESIGN.md). Stage S1: report only. */ +import type * as ts from "../ts7/adapter.js"; +import { analyzeShapes } from "./analyze.js"; +import { buildShapeReport } from "./report.js"; +import { computeConstantGraphs, constantGraphReport } from "./constant-graphs.js"; + +export { analyzeShapes } from "./analyze.js"; +export { buildShapeReport } from "./report.js"; + +/** SCRIPTC_DEBUG_SHAPES: `1` reports package code in hot functions, + * `verbose` every package site, `all` user files too, any other value only + * files whose path contains it. Writes to stderr; changes nothing else. */ +export function reportShapes(program: ts.Program, filter: string): void { + const start = performance.now(); + const analysis = analyzeShapes(program, program.getTypeChecker()); + // `const`: the constant object graphs (DESIGN-constant-objects.md, stage 1). + const lines = + filter === "const" + ? constantGraphReport(analysis, computeConstantGraphs(analysis)) + : buildShapeReport(analysis, filter).lines; + const ms = (performance.now() - start).toFixed(0); + process.stderr.write(`[shapes] ${ms} ms\n${lines.map((l) => `[shapes] ${l}`).join("\n")}\n`); +} diff --git a/packages/compiler/src/frontend/shape-inference/report.ts b/packages/compiler/src/frontend/shape-inference/report.ts new file mode 100644 index 000000000..c18d9eb56 --- /dev/null +++ b/packages/compiler/src/frontend/shape-inference/report.ts @@ -0,0 +1,374 @@ +/* Shape inference stage S1: the plan the analysis would hand the lowering, + * as a report (DESIGN.md §4.5, §5.5). Nothing here changes compilation. + * + * Per allocation site of package code, the verdict: + * P local — a native record would be exact: no reflection, no added + * keys, no computed writes, and its objects never escape; + * P escaping — exact as a record behind identity-keeping capsules: it + * escapes, but the census leaves every later operation + * answerable (fields untyped code may write become dyn); + * G — keeps today's dynamic object, with the rule that decided. + * Plus class field types, the census, and the hot functions' value types. */ +import * as ts from "../ts7/adapter.js"; +import { OBJECT_PROTOTYPE_MEMBERS, type Atom } from "./domain.js"; +import { locLabel, type FunctionInfo, type LiteralSite, type ShapeAnalysis } from "./analyze.js"; + +export interface SiteVerdict { + readonly site: LiteralSite; + readonly label: string; + readonly verdict: "P-local" | "P-escaping" | "G"; + readonly reasons: string[]; + /** P-escaping: fields untyped code may write (they become dyn slots). */ + readonly writableByTop: string[]; + readonly freezable: boolean; + readonly brand: boolean; +} + +export interface ShapeReport { + readonly sites: SiteVerdict[]; + readonly hot: Set; + readonly lines: string[]; +} + +const MAX_ATOMS_SHOWN = 6; + +function fieldKeys(fields: ReadonlyMap | undefined): string[] { + return fields === undefined ? [] : [...fields.keys()]; +} + +function fieldEntries(fields: ReadonlyMap | undefined): [string, number][] { + return fields === undefined ? [] : [...fields.entries()]; +} + +export function describeValue(analysis: ShapeAnalysis, node: number): string { + const atoms = [...analysis.nodes[node]!.atoms]; + if (atoms.length === 0) return "∅"; + const labels = [...new Set(atoms.map((a) => a.label))]; + labels.sort((a, b) => (a === "⊤" ? 1 : b === "⊤" ? -1 : a.localeCompare(b))); + const shown = labels.slice(0, MAX_ATOMS_SHOWN).join(" | "); + if (labels.length <= MAX_ATOMS_SHOWN) return shown; + return `${shown} | +${labels.length - MAX_ATOMS_SHOWN}${labels.includes("⊤") ? " | ⊤" : ""}`; +} + +function atomsOf(analysis: ShapeAnalysis, node: number): Set { + return analysis.nodes[node]!.atoms; +} + +/** Functions reached from call sites in loops (or loop callbacks), and + * everything they call: the static hotness heuristic (DESIGN.md §8.1). */ +export function hotFunctions(analysis: ShapeAnalysis): Set { + const hot = new Set(); + const callees = new Map(); + const work: FunctionInfo[] = []; + for (const c of analysis.calls) { + let l = callees.get(c.caller); + if (l === undefined) callees.set(c.caller, (l = [])); + l.push(c.callee); + if (c.inLoop && !hot.has(c.callee)) { + hot.add(c.callee); + work.push(c.callee); + } + } + while (work.length > 0) { + const f = work.pop()!; + for (const g of callees.get(f) ?? []) { + if (!hot.has(g)) { + hot.add(g); + work.push(g); + } + } + } + return hot; +} + +export function buildShapeReport(analysis: ShapeAnalysis, filter: string): ShapeReport { + const lines: string[] = []; + const all = filter === "all" || filter === "verbose"; + const verbose = filter === "verbose"; + const fileFilter = filter !== "1" && !all ? filter : null; + const wanted = (n: ts.Node, inPackage: boolean): boolean => { + if (fileFilter !== null) return n.getSourceFile().fileName.includes(fileFilter); + return all || inPackage; + }; + + // Census facts: operations whose receiver may be ⊤ reach every escaped atom. + + // Index ops by the site atoms they can reach directly. + const byAtom = new Map(); + const topOps: typeof analysis.ops = []; + for (const o of analysis.ops) { + const atoms = atomsOf(analysis, o.recv); + for (const a of atoms) { + if (a.kind !== "object" && a.kind !== "array") continue; + let l = byAtom.get(a); + if (l === undefined) byAtom.set(a, (l = [])); + l.push(o); + } + if (atoms.has(analysis.top)) topOps.push(o); + } + + // Key-order groups: sites with the same key set and different orders need brands. + const orders = new Map>(); + for (const s of analysis.sites) { + if (s.kind !== "object" || s.keys === null) continue; + const set = [...s.keys].sort().join(","); + let o = orders.get(set); + if (o === undefined) orders.set(set, (o = new Set())); + o.add(s.keys.join(",")); + } + + const verdicts: SiteVerdict[] = []; + for (const s of analysis.sites) { + if (!s.inPackage && !all && fileFilter === null) continue; + const reasons: string[] = []; + const escapedWhy = analysis.escaped.get(s.atom); + const escapes = escapedWhy !== undefined; + if (s.hasAccessor) reasons.push("accessor in literal"); + if (s.hasProto) reasons.push(s.kind === "array" ? "hole in literal" : "__proto__ in literal"); + if (s.hasComputedKey) reasons.push("computed key in literal"); + for (const sp of s.spreads) + if (atomsOf(analysis, sp).has(analysis.top)) reasons.push("spread of ⊤"); + const direct = byAtom.get(s.atom) ?? []; + const ops = escapes ? [...direct, ...topOps] : direct; + const keys = new Set(s.keys ?? []); + const fieldsOf = analysis.fields.get(s.atom); + const knownKeys = + s.keys !== null ? keys : new Set([...fieldKeys(fieldsOf)].filter((k) => k !== "*")); + let freezable = false; + const seen = new Set(); + const note = (r: string): void => { + if (!seen.has(r)) { + seen.add(r); + reasons.push(r); + } + }; + for (const o of ops) { + const viaTop = !atomsOf(analysis, o.recv).has(s.atom); + const where = `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`; + switch (o.kind) { + case "delete": + case "defineProperty": + case "setPrototypeOf": + case "seal": + case "proxy": + note(`${o.kind} at ${where}`); + break; + case "assign": + note(`Object.assign target at ${where}`); + break; + case "freeze": + freezable = true; + break; + case "computedWrite": + if (!viaTop) + note(s.kind === "array" ? `index write at ${where}` : `computed write at ${where}`); + break; + case "lengthWrite": + if (s.kind === "array") note(`length write at ${where}`); + break; + case "write": + if (s.kind === "object" && !viaTop && o.name !== null && !knownKeys.has(o.name)) + note(`adds key '${o.name}' at ${where}`); + break; + case "read": + if ( + s.kind === "object" && + !underPromiseGuard(o.node) && + escapes && + o.name !== null && + !viaTop && + !knownKeys.has(o.name) && + !OBJECT_PROTOTYPE_MEMBERS.has(o.name) + ) + note(`reads absent key '${o.name}' at ${where} (escapes)`); + break; + default: + break; + } + } + let brand = false; + if (s.kind === "object" && s.keys !== null) { + const o = orders.get([...s.keys].sort().join(",")); + if (o !== undefined && o.size > 1) brand = true; + } + const writableByTop: string[] = []; + if (escapes) { + if (analysis.topComputedWrites.length > 0) writableByTop.push("*"); + else for (const k of knownKeys) if (analysis.topWriteNames.has(k)) writableByTop.push(k); + } + const verdict: SiteVerdict["verdict"] = + reasons.length > 0 ? "G" : escapes ? "P-escaping" : "P-local"; + const label = s.atom.label; + if (escapes) reasons.push(`escapes: ${escapedWhy}`); + verdicts.push({ site: s, label, verdict, reasons, writableByTop, freezable, brand }); + } + + const hot = hotFunctions(analysis); + const hotNodes = new Set([...hot].map((f) => f.node)); + + // ---- print ---- + const pkgFns = [...analysis.functions.values()].filter((f) => f.inPackage); + lines.push( + `analyzed ${analysis.files.length} files (${analysis.packageFiles} package JS), ${analysis.functions.size} functions (${pkgFns.length} in packages), ${analysis.atoms.atoms.length} atoms, ${analysis.nodes.length} value nodes, ${analysis.steps} steps${analysis.budgetExceeded ? " — BUDGET EXCEEDED, results incomplete" : ""}`, + ); + const topNames = [...analysis.topWriteNames.entries()].map( + ([n, node]) => `${n} (${locLabel(node)})`, + ); + lines.push( + `census: writes on ⊤ receivers: ${topNames.length === 0 ? "none" : topNames.slice(0, 20).join(", ")}${topNames.length > 20 ? ` +${topNames.length - 20}` : ""}`, + ); + lines.push( + `census: computed writes on ⊤ receivers: ${analysis.topComputedWrites.length === 0 ? "none" : analysis.topComputedWrites.slice(0, 8).map(locLabel).join(", ")}`, + ); + const topReflect = topOps.filter( + (o) => + o.kind !== "read" && + o.kind !== "computedRead" && + o.kind !== "write" && + o.kind !== "computedWrite", + ); + lines.push( + `census: reflection on ⊤ receivers: ${topReflect.length === 0 ? "none" : topReflect.map((o) => `${o.kind} (${locLabel(o.node)})`).join(", ")}`, + ); + const escapedPkg = [...analysis.escaped.keys()].filter( + (a) => + a.node !== undefined && + wanted(a.node, a.node.getSourceFile().fileName.includes("/node_modules/")), + ); + lines.push( + `escaped atoms: ${analysis.escaped.size} (${escapedPkg.length} shown below where relevant)`, + ); + const firstEscapes = [...analysis.escaped.entries()] + .filter(([, why]) => !why.startsWith("via ")) + .slice(0, 12); + for (const [atom, why] of firstEscapes) lines.push(` escape root: ${atom.label}: ${why}`); + + const counts = { "P-local": 0, "P-escaping": 0, G: 0 }; + const hotCounts = { "P-local": 0, "P-escaping": 0, G: 0 }; + const inHot = (n: ts.Node): boolean => { + let p: ts.Node | undefined = n.parent; + while (p !== undefined) { + if (hotNodes.has(p)) return true; + p = p.parent; + } + return false; + }; + for (const v of verdicts) { + counts[v.verdict]++; + if (inHot(v.site.node)) hotCounts[v.verdict]++; + } + lines.push( + `sites: ${verdicts.length} (P local ${counts["P-local"]}, P escaping ${counts["P-escaping"]}, G ${counts.G}); in hot functions: P local ${hotCounts["P-local"]}, P escaping ${hotCounts["P-escaping"]}, G ${hotCounts.G}`, + ); + for (const v of verdicts) { + if (!wanted(v.site.node, v.site.inPackage)) continue; + if (!verbose && !inHot(v.site.node)) continue; + const extra: string[] = []; + if (v.brand) extra.push("needs brand (key order)"); + if (v.freezable) extra.push("freezable"); + if (v.writableByTop.length > 0) extra.push(`⊤-writable: ${v.writableByTop.join(",")}`); + const fields = analysis.fields.get(v.site.atom); + const ftypes = + v.site.kind === "array" + ? `elem: ${analysis.elements.has(v.site.atom) ? describeValue(analysis, analysis.elements.get(v.site.atom)!) : "∅"}` + : fieldEntries(fields) + .filter(([k]) => k !== "*") + .map(([k, id]) => `${k}: ${describeValue(analysis, id)}`) + .join("; "); + lines.push( + `site ${v.label}${inHot(v.site.node) ? " [hot]" : ""}: ${v.verdict}${v.reasons.length > 0 ? ` — ${v.reasons.join("; ")}` : ""}${extra.length > 0 ? ` [${extra.join("; ")}]` : ""}`, + ); + if (ftypes.length > 0) lines.push(` ${ftypes}`); + } + + // Class fields. + for (const atom of analysis.atoms.atoms) { + if (atom.kind !== "instance" || atom.node === undefined) continue; + const inPackage = atom.node.getSourceFile().fileName.includes("/node_modules/"); + if (!wanted(atom.node, inPackage)) continue; + const fm = analysis.fields.get(atom); + if (fm === undefined || fm.size === 0) continue; + const esc = analysis.escaped.get(atom); + const parts = [...fm.entries()].map(([k, id]) => { + const w = + esc !== undefined && + !k.startsWith("#") && + (analysis.topComputedWrites.length > 0 || analysis.topWriteNames.has(k)) + ? " (⊤-writable)" + : ""; + return `${k}: ${describeValue(analysis, id)}${w}`; + }); + lines.push( + `class ${atom.label}${esc !== undefined ? ` (escapes: ${esc})` : ""}: ${parts.join("; ")}`, + ); + } + + // Hot functions. + const hotPkg = [...hot].filter((info) => wanted(info.node, info.inPackage)); + lines.push(`hot functions: ${hotPkg.length} (clones per receiver class counted separately)`); + for (const info of hotPkg) { + const n = info.node; + const params = info.params.map((p, i) => `p${i}=${describeValue(analysis, p)}`).join(", "); + const flags = [ + info.usesArguments ? "uses arguments" : "", + analysis.escaped.size > 0 && [...analysis.escaped.keys()].some((a) => a.node === n) + ? "escapes" + : "", + ].filter((x) => x !== ""); + lines.push( + `fn ${info.label}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}: this=${describeValue(analysis, info.thisNode)}${params.length > 0 ? `, ${params}` : ""}, ret=${describeValue(analysis, info.ret)}`, + ); + } + const why = process.env["SCRIPTC_DEBUG_SHAPES_WHY"]; + if (why !== undefined) { + for (const info of analysis.functions.values()) { + if (!info.label.startsWith(why)) continue; + for (const [what, node] of [ + ["ret", info.ret], + ...info.params.map((p, i) => [`p${i}`, p] as const), + ] as const) { + if (!analysis.nodes[node]!.atoms.has(analysis.top)) continue; + lines.push(`why ⊤ in ${info.label} ${what}:`); + let cur: number | string | undefined = node; + for (let i = 0; i < 40 && typeof cur === "number"; i++) { + lines.push(` ${analysis.nodeLabel.get(cur) ?? `node ${cur}`}`); + cur = analysis.topCause.get(cur); + } + if (typeof cur === "string") lines.push(` seed: ${cur}`); + } + } + } + return { sites: verdicts, hot, lines }; +} + +/** A read inside the true arm of a promise test (`isAsync(x)`, + * `x instanceof Promise`): flow-sensitive narrowing removes the record + * arms there. Stage S1's locals are flow-insensitive, so the report + * recognizes the guard syntactically. */ +function underPromiseGuard(node: ts.Node): boolean { + let child: ts.Node = node; + let p = node.parent; + while (p !== undefined && !ts.isFunctionLike(p)) { + let cond: ts.Expression | undefined; + if (ts.isIfStatement(p) && p.thenStatement === child) cond = p.expression; + if (ts.isConditionalExpression(p) && p.whenTrue === child) cond = p.condition; + if (cond !== undefined && isPromiseTest(cond)) return true; + child = p; + p = p.parent; + } + return false; +} + +function isPromiseTest(e: ts.Expression): boolean { + if (ts.isParenthesizedExpression(e)) return isPromiseTest(e.expression); + if (ts.isBinaryExpression(e)) { + if (e.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) + return ts.isIdentifier(e.right) && e.right.text === "Promise"; + if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) + return isPromiseTest(e.left) || isPromiseTest(e.right); + return false; + } + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) + return /^is(Async|Promise|Thenable)$/.test(e.expression.text); + return false; +} diff --git a/packages/compiler/src/frontend/token-equivalence.test.ts b/packages/compiler/src/frontend/token-equivalence.test.ts index 880a07221..7bbe08929 100644 --- a/packages/compiler/src/frontend/token-equivalence.test.ts +++ b/packages/compiler/src/frontend/token-equivalence.test.ts @@ -1,8 +1,10 @@ import { expect, test } from "vitest"; -import { tokenEquivalentSource } from "./token-equivalence.js"; +import { tokenEquivalentSource, tokenPositionEquivalentSource } from "./token-equivalence.js"; const equivalent = (previous: string, current: string, path = "/src/a.ts") => tokenEquivalentSource(path, previous, current); +const positionEquivalent = (previous: string, current: string, path = "/src/a.ts") => + tokenPositionEquivalentSource(path, previous, current); test("whitespace and plain comments are trivia", () => { expect(equivalent("const a = 1;\n", "const a = 1; // one\n")).toBe(true); @@ -105,3 +107,50 @@ test("text the scanner cannot classify is never equivalent", () => { expect(equivalent("const a = ; // a\n", "const a = ; // b\n", "/src/a.tsx")).toBe(false); expect(equivalent('const s = "open\n', 'const s = "open // \n')).toBe(false); }); + +test("a / after a non-null assertion divides", () => { + expect( + equivalent("const q = data[i]! / 7; // ratio\n", "const q = data[i]! / 7; // the ratio\n"), + ).toBe(true); + expect( + equivalent("const q = value! / 7; // ratio\n", "const q = value! / 7; // the ratio\n"), + ).toBe(true); + expect(equivalent("const q = this! / 7; // ratio\n", "const q = this! / 7; // the ratio\n")).toBe( + true, + ); + // A prefix `!` negates a regular expression, whose `//` is code. + expect( + equivalent("const t = !/[//]/.test(s); c = 1;\n", "const t = !/[//]/.test(s); c = 2;\n"), + ).toBe(false); + expect(equivalent("return !/[//]/.test(s); // one\n", "return !/[//]/.test(s); // two\n")).toBe( + true, + ); + // After a line break, `!` starts a new expression. + expect(equivalent("a\n!/[//]/.test(s); c = 1;\n", "a\n!/[//]/.test(s); c = 2;\n")).toBe(false); + // `f()!` and `if (a) !` read the same up to the `/`. + expect(equivalent("const q = f()! / 2; // one\n", "const q = f()! / 2; // two\n")).toBe(false); +}); + +test("position equivalence also keeps every token's line and column", () => { + const source = "const a = 1; // one\nconst b = a; /* two */ f(b);\n"; + // A comment's text within its line, after the line's last token. + expect( + positionEquivalent(source, "const a = 1; // the first\nconst b = a; /* two */ f(b);\n"), + ).toBe(true); + // Comments and whitespace after the last token, and trailing whitespace. + expect(positionEquivalent(source, source + "// appended\n\n")).toBe(true); + expect( + positionEquivalent(source, "const a = 1; // one\nconst b = a; /* two */ f(b); \n"), + ).toBe(true); + expect(positionEquivalent(source, source.replaceAll("\n", "\r\n"))).toBe(true); + // A longer comment before a token moves its column; a new line moves the + // lines below it. Both stay token-equivalent. + const column = "const a = 1; // one\nconst b = a; /* the second */ f(b);\n"; + const line = "// header\nconst a = 1; // one\nconst b = a; /* two */ f(b);\n"; + expect(positionEquivalent(source, column)).toBe(false); + expect(positionEquivalent(source, line)).toBe(false); + expect(equivalent(source, column)).toBe(true); + expect(equivalent(source, line)).toBe(true); + // A token edit is never equivalent. + expect(positionEquivalent(source, source.replace("1", "2"))).toBe(false); +}); diff --git a/packages/compiler/src/frontend/token-equivalence.ts b/packages/compiler/src/frontend/token-equivalence.ts index 7a0f6cc79..9f6fd4613 100644 --- a/packages/compiler/src/frontend/token-equivalence.ts +++ b/packages/compiler/src/frontend/token-equivalence.ts @@ -17,7 +17,10 @@ * before it. After `)` it divides unless the parenthesis closes an `if`, * `while`, `for`, `for await`, or `with` header. After `}`, `++` or * `--` the scanner cannot tell which, so it gives up, as it does for - * escaped identifiers, which can spell keywords. + * escaped identifiers, which can spell keywords. After `!` in + * TypeScript it divides when the `!` follows an operand on the same line + * (a non-null assertion, `x! / 2`) and gives up when that operand is a + * `)`, `}`, `++` or `--`. * - A `/` with a line break (or a comment that spans lines) since the * previous token gives up: whether automatic semicolon insertion ends the * statement before it decides how it reads. @@ -35,6 +38,8 @@ interface Token { text: string; comment: boolean; lineBreakBefore: boolean; + /** The offset of the token's first code unit. */ + start: number; } /** Keywords after which `/` starts a regular expression. */ @@ -135,14 +140,20 @@ function scan(path: string, source: string): Token[] | null { // A line break, or a comment spanning lines, since the last token other // than a comment. let breakSinceCode = false; - // The previous token and the one before it, and whether a `)` closed a - // statement header. - const last: { token: string | null; before: string | null } = { token: null, before: null }; + // The previous token and the one before it, whether a line break came + // before the previous token, and whether a `)` closed a statement header. + const last: { token: string | null; before: string | null; broke: boolean } = { + token: null, + before: null, + broke: false, + }; let closedHeader = false; + // Every token ends at `index` when it is pushed. const push = (text: string, comment = false): void => { - tokens.push({ text, comment, lineBreakBefore }); + tokens.push({ text, comment, lineBreakBefore, start: index - text.length }); lineBreakBefore = false; if (!comment) { + last.broke = breakSinceCode; breakSinceCode = false; last.before = last.token; last.token = text; @@ -291,7 +302,19 @@ function scan(path: string, source: string): Token[] | null { else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`")) regex = false; else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false; - else regex = true; + else if (before === "!" && typeScript && !last.broke && last.before !== null) { + // `x! / 2` divides: a `!` right after an operand on the same line is + // a non-null assertion. `!/re/.test(s)` negates a regular expression. + const operand = last.before; + if (operand === ")" || operand === "}" || operand === "++" || operand === "--") return null; + regex = !( + operand === "]" || + operand.startsWith("'") || + operand.startsWith('"') || + operand.endsWith("`") || + (/^[A-Za-z0-9_$]/.test(operand) && !EXPRESSION_KEYWORDS.has(operand)) + ); + } else regex = true; if (regex) { const start = index; index++; @@ -373,9 +396,33 @@ function scan(path: string, source: string): Token[] | null { return tokens; } -/** Whether `current` differs from `previous` only in whitespace and in - * comments that cannot affect compilation. */ -export function tokenEquivalentSource(path: string, previous: string, current: string): boolean { +/** The line and column of each token's start, with the line terminators + * source locations count (backend/source-locations.ts). */ +function tokenPositions(source: string, tokens: readonly Token[]): number[] { + const positions: number[] = []; + let line = 0; + let lineStart = 0; + let offset = 0; + for (const token of tokens) { + for (; offset < token.start; offset++) { + const code = source.charCodeAt(offset); + if (code === 13 && source.charCodeAt(offset + 1) === 10) offset++; + if (code === 10 || code === 13 || code === 0x2028 || code === 0x2029) { + line++; + lineStart = offset + 1; + } + } + positions.push(line, token.start - lineStart); + } + return positions; +} + +function equivalentSources( + path: string, + previous: string, + current: string, + positions: boolean, +): boolean { if (previous === current) return true; const left = scan(path, previous); if (left === null) return false; @@ -387,5 +434,27 @@ export function tokenEquivalentSource(path: string, previous: string, current: s if (a.text !== b.text || a.comment !== b.comment) return false; if (index > 0 && a.lineBreakBefore !== b.lineBreakBefore) return false; } - return true; + if (!positions) return true; + const before = tokenPositions(previous, left); + const after = tokenPositions(current, right); + return before.every((value, index) => value === after[index]); +} + +/** Whether `current` differs from `previous` only in whitespace and in + * comments that cannot affect compilation. */ +export function tokenEquivalentSource(path: string, previous: string, current: string): boolean { + return equivalentSources(path, previous, current, false); +} + +/** Whether `current` is token-equivalent to `previous` (tokenEquivalentSource) + * and every token keeps its line and column: the edit leaves the source + * positions debug information records unchanged (a comment's text within + * its line, comments and whitespace after the last token, trailing + * whitespace). */ +export function tokenPositionEquivalentSource( + path: string, + previous: string, + current: string, +): boolean { + return equivalentSources(path, previous, current, true); } diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts index f041a6cb5..e87db5f1a 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts @@ -219,3 +219,53 @@ test("a supplied file that changes is a different entry", () => { expect(store.entries.size).toBe(2); writeFileSync(fallback, fallbackText); }); + +/** The program's files, recording which ones were read. */ +function recording(texts: Record): ((name: string) => SourceFile | undefined) & { + read: string[]; +} { + const inner = files(texts); + const read: string[] = []; + return Object.assign((name: string) => (read.push(name), inner(name)), { read }); +} + +test("a program of modules stores its answer without reading the supplied files", () => { + const store = memoryStore(); + setLibDiagnosticsStore(store, "checker"); + const sources = recording({ [main]: "export const n: number = 'x';\n" }); + reusedLibDiagnostics( + sessionProgram({ semantic: new Map([[main, []]]) }), + names, + supplied, + sources, + ); + expect(sources.read).toEqual([main]); + const [text] = [...store.entries.values()]; + expect(JSON.parse(text!).globals).toBeNull(); + // A later program of modules reuses it, again without reading them. + const again = recording({ [main]: "export const n: number = 'x';\n" }); + const program = sessionProgram({ semantic: new Map([[main, []]]) }); + expect(reusedLibDiagnostics(program, names, supplied, again)).not.toBeNull(); + expect(program.asked).toEqual([`checked ${main}`]); + expect(again.read).toEqual([main]); +}); + +test("a script that uses an answer stored without global names collects and stores them", () => { + const store = memoryStore(); + setLibDiagnosticsStore(store, "checker"); + const semantic = new Map([[main, []]]); + reusedLibDiagnostics(sessionProgram({ semantic }), names, supplied, moduleFile); + const script = recording({ [main]: "const total = 1;\n" }); + const program = sessionProgram({ semantic }); + expect(reusedLibDiagnostics(program, names, supplied, script)).not.toBeNull(); + expect(program.asked).toEqual([`checked ${main}`]); + expect(script.read).toEqual([main, lib, fallback]); + const [text] = [...store.entries.values()]; + expect(new Set(JSON.parse(text!).globals)).toEqual(new Set(["NaN", "Array", "process"])); + // A script that declares one of them runs the full check, and the entry keeps its names. + const clash = sessionProgram({ semantic }); + reusedLibDiagnostics(clash, names, supplied, files({ [main]: "declare var NaN: number;\n" })); + expect(clash.asked[0]).toBe("getGlobalDiagnostics"); + expect(store.entries.size).toBe(1); + expect(JSON.parse([...store.entries.values()][0]!).globals).not.toBeNull(); +}); diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts index 827b337bd..30c6e81f0 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts @@ -10,7 +10,14 @@ * (getDiagnosticsOfCheckedFiles), and the semantic diagnostics merge in the * order the full check sorts them, by file name. An answer is stored only * when the full check reports no global diagnostics: the global diagnostics - * of checking the other files are then the full check's. */ + * of checking the other files are then the full check's. + * + * The global names the supplied files declare matter only to programs with + * scripts, and collecting them reads every supplied file's syntax tree, + * which a program of modules does not otherwise read (about 4 MB at the + * peak of a small build). An answer stored by such a program leaves them + * out; the first program with scripts that uses the answer collects them + * and stores them with it. */ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import type { Node, SourceFile } from "./ast-types.js"; @@ -25,7 +32,7 @@ export interface LibDiagnosticsStore { write(key: string, text: string): void; } -const SCHEMA = "scriptc.lib-diagnostics.v1"; +const SCHEMA = "scriptc.lib-diagnostics.v2"; let configured: { store: LibDiagnosticsStore; checker: string } | null = null; @@ -89,7 +96,14 @@ export function reusedLibDiagnostics( const key = entryKey(checker, options, libs); if (key === null) return null; const stored = readEntry(store, key, libs); - if (stored !== null && !collides(stored.globals)) { + // An answer stored without the supplied files' global names: a program + // with scripts collects them and stores them with the answer. + let globals = stored?.globals ?? null; + if (stored !== null && globals === null && scripts.length > 0) { + globals = suppliedGlobalNames(libs, sourceFile); + if (globals !== null) writeEntry(store, key, globals, Object.fromEntries(stored.files)); + } + if (stored !== null && (scripts.length === 0 || (globals !== null && !collides(globals)))) { const checked = program.checkedFileDiagnosticData(own); if (checked === null) return null; const byFile = new Map(stored.files); @@ -104,20 +118,35 @@ export function reusedLibDiagnostics( const semantic = program.diagnosticData("getSemanticDiagnostics"); // A server that cannot check only some files could never use the answer. if (global.length === 0 && stored === null && program.checkedFileDiagnosticData([]) !== null) { - const globals = suppliedGlobalNames(libs, sourceFile); - if (globals !== null && !collides(globals)) { + const names = scripts.length === 0 ? null : suppliedGlobalNames(libs, sourceFile); + if (scripts.length === 0 || (names !== null && !collides(names))) { const files: Record = {}; for (const name of libs) files[name] = program.diagnosticData("getSemanticDiagnostics", name); - try { - store.write(key, JSON.stringify({ schema: SCHEMA, globals: [...globals], files })); - } catch { - // Best effort: a failed write is a later miss. - } + writeEntry(store, key, names, files); } } return { semantic, global }; } +/** Stores an answer: the supplied files' diagnostics, and their global names + * when they were collected (null otherwise). Best effort: a failed write is + * a later miss. */ +function writeEntry( + store: LibDiagnosticsStore, + key: string, + globals: ReadonlySet | null, + files: Record, +): void { + try { + store.write( + key, + JSON.stringify({ schema: SCHEMA, globals: globals === null ? null : [...globals], files }), + ); + } catch { + // A failed write is a later miss. + } +} + /** Whether a file cannot add to or change the global declarations the * supplied files declare: a module with no `declare global`, no module * augmentation and no UMD `export as namespace` declaration. */ @@ -276,7 +305,7 @@ function readEntry( store: LibDiagnosticsStore, key: string, libs: readonly string[], -): { files: Map; globals: Set } | null { +): { files: Map; globals: Set | null } | null { let text: string | null; try { text = store.read(key); @@ -290,7 +319,11 @@ function readEntry( } catch { return null; } - if (entry.schema !== SCHEMA || entry.files === undefined || !Array.isArray(entry.globals)) + if ( + entry.schema !== SCHEMA || + entry.files === undefined || + (entry.globals !== null && !Array.isArray(entry.globals)) + ) return null; const files = new Map(); for (const name of libs) { @@ -298,7 +331,10 @@ function readEntry( if (!Array.isArray(list)) return null; files.set(name, list); } - return { files, globals: new Set(entry.globals.filter((name) => typeof name === "string")) }; + const globals = Array.isArray(entry.globals) + ? new Set(entry.globals.filter((name): name is string => typeof name === "string")) + : null; + return { files, globals }; } /** Each file's diagnostics (already in the checker's order) in the order the diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index e6c0be7cd..e8ffc29fb 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -132,6 +132,10 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // binding types as any and reached uses fence instead of passing unchecked 2307, 2792, + // `delete` of a property the inference typed required (2790): JS deletes + // any configurable property, and the lowering checks each delete's + // receiver itself + 2790, // useUnknownInCatchVariables' annotation demand ("'e' is of type // 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT // annotate its variable, so the demand is unsatisfiable in source — diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 8dc066a89..8b72dcbae 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1747,9 +1747,17 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx): return { kind: "generator", async: true, ...channels }; } -/** The native fetch handles (Headers, Request, Response), optionally in - * arrays or with null/undefined: checked-dynamic handles wherever they are - * stored, like the worker handles above. */ +const FETCH_HANDLE_NAMES: ReadonlySet = new Set([ + "Headers", + "Request", + "Response", + "AbortController", + "AbortSignal", +]); + +/** The native fetch handles (Headers, Request, Response, AbortController, + * AbortSignal), optionally in arrays or with null/undefined: checked-dynamic + * handles wherever they are stored, like the worker handles above. */ export function isFetchHandleType( type: ts.Type, ctx: Pick, @@ -1765,9 +1773,7 @@ export function isFetchHandleType( const symbol = member.getSymbol(); if ( !symbol || - (ts.symbolName(symbol) !== "Headers" && - ts.symbolName(symbol) !== "Request" && - ts.symbolName(symbol) !== "Response") || + !FETCH_HANDLE_NAMES.has(ts.symbolName(symbol)) || !symbol.declarations.some( (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && @@ -7051,6 +7057,43 @@ function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null return null; } +/** A mapped type reached through a type alias whose arguments mention no + * type parameter (`Record`, `Catalog`, + * a module-level alias without arguments): + * the checker computed its members from concrete inputs, so an empty + * result really is the empty object type, unlike `Partial` inside a + * generic body. */ +function resolvedMappedAlias(t: ts.Type, ctx: TypeMapperCtx): boolean { + if (!isMappedShape(t) || t.getAliasSymbol() === undefined) return false; + const args = t.getAliasTypeArguments(); + const seen = new Set(); + const concrete = (part: ts.Type): boolean => { + if (part.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Index)) return false; + if (part.flags & (ts.TypeFlags.IndexedAccess | ts.TypeFlags.Conditional)) return false; + if (seen.has(part)) return true; + seen.add(part); + if (part.isUnionType() || part.isIntersectionType()) + return ts.constituentTypes(part).every(concrete); + if (!part.getAliasTypeArguments().every(concrete)) return false; + return ( + (part.flags & ts.TypeFlags.Object) === 0 || + ((part as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0 || + ctx.checker.getTypeArguments(part as ts.TypeReference).every(concrete) + ); + }; + // An alias without type arguments is concrete when it is declared at + // module level (a function body's alias may mention that function's type + // parameters). + if (args.length === 0) + return t + .getAliasSymbol()! + .declarations.every( + (d) => + ts.isTypeAliasDeclaration(d) && (ts.isSourceFile(d.parent) || ts.isModuleBlock(d.parent)), + ); + return args.every(concrete); +} + /** True for MAPPED-type results — `Partial`, `Record<"a", n>`, * whatever `Pick`/`Omit` reduce to. Their shape is computed by the checker, * not declared anywhere: the only declaration behind them is the utility @@ -8005,8 +8048,12 @@ function mapRecordTypeInner( // still unknown there) — interning `{}` would give a wrong type. The // degenerate empties (`Partial<{}>`, `Omit`) go with it. // An INDEX-SIGNATURE shape is exempt: `Record` legitimately - // has zero declared members — the signature is the shape. - if (computed && props.length === 0 && !indexValue) return null; + // has zero declared members — the signature is the shape. So is a mapped + // alias instantiated over concrete arguments (`Record`, a + // catalog type whose key filter keeps no key): the checker resolved it, + // and it is the empty object type below. + if (computed && props.length === 0 && !indexValue && !resolvedMappedAlias(widened, ctx)) + return null; // An empty object type — `{}` (spelled, the checker's shared // intrinsic, or inferred from an empty object literal), `interface // Empty {}` — is tsc's TOP type over non-nullish values: every number, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index e2d1c97cc..4fb64968b 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -875,6 +875,7 @@ async function prepareExecutableInput( pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", runtimeAbiMarker: useRuntimePack, + unwindAbi: buildPlatform === "linux", objectAudit: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 390c7fbc3..7317116db 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -591,6 +591,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "util.styleText", "util.toUSVString", "util.inherits", + "util.inheritsPrototype", "util.deprecate", "util.callbackify", "util.promisifyDyn", @@ -976,6 +977,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "assert.noErrorPredicate", "assert.regexDynTest", "assert.expectsErrDyn", + "assert.throwsValidate", "assert.ifErrorErr", "assert.ifErrorF64", "assert.ifErrorStr", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3de329388..8516ecba0 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1146,6 +1146,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * arguments (scr_util_callable.c). The function results are native dyn * wrappers; call sites with a static function type check them back. */ "util.inherits": { argTypes: [DYN, DYN], result: VOID }, + "util.inheritsPrototype": { argTypes: [DYN, DYN, DYN], result: VOID }, "util.deprecate": { argTypes: [DYN, DYN, DYN, DYN], result: DYN }, "util.callbackify": { argTypes: [DYN], result: DYN }, /** util.promisify over an untyped function value: a native dyn function @@ -2608,6 +2609,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ result: CHILD_T, }, "child.connected": { argTypes: [CHILD_T], result: BOOL }, + "child.hasChannel": { argTypes: [CHILD_T], result: BOOL }, "child.send": { argTypes: [CHILD_T, STRING], result: BOOL }, "child.sendCb": { argTypes: [CHILD_T, STRING, null], result: BOOL }, "child.disconnect": { argTypes: [CHILD_T], result: VOID }, @@ -4436,6 +4438,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // The equality quartet over checked-dynamic operands (the frontend // boxes a static side into the checked-dynamic tree first). "assert.eqDyn": { argTypes: [DYN, DYN, BOOL, BOOL, STRING, BOOL], result: VOID }, + /** A message argument that is not a string, registered for the next + * failing assertion (scr_assert_message_value). */ + "assert.messageValue": { argTypes: [DYN, BOOL], result: VOID }, + /** An assert method as a function value, by canonical name. */ + "assert.methodValue": { argTypes: [STRING], result: DYN }, + "assert.messageClear": { argTypes: [], result: VOID }, "assert.looseResult": { argTypes: [BOOL, BOOL, STRING, STRING, STRING, BOOL], result: VOID }, // The throws(fn, {shape}) accumulator: begin/slot calls never throw; // shapeEnd throws the Comparison diff. The error slot is the @@ -4461,6 +4469,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * against the caught value's; a mismatch throws the deep-equal * AssertionError (scr_assert.c). MAY THROW by design. */ "assert.expectsErrDyn": { argTypes: [DYN, DYN, STRING, BOOL], result: VOID }, + /** A validation function against the caught value (Node's expectedException). */ + "assert.throwsValidate": { argTypes: [DYN, DYN, STRING, STRING, BOOL], result: VOID }, // assert.ifError's typed entries (always throw; unit args never // lower). The error slot is program-dependent — any %Error-hierarchy // class, root or subclass (the insp.error precedent, null slot). @@ -4899,6 +4909,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * lib-call argument passes the time value itself, and any other use * creates the Date. */ "date.fromTime": { argTypes: [F64], result: DATE_T }, + /** Date.prototype's field setters over a time value + * (scr_date_set_fields): time value, setter code, argument count and four + * arguments; answers the new time value. */ + "date.setFields": { argTypes: [F64, F64, F64, F64, F64, F64, F64], result: F64 }, + /** TimeClip of a number (setTime's argument). */ + "date.timeClip": { argTypes: [F64], result: F64 }, + /** Stores a time value into a Date object and answers it (a setter's + * write). */ + "date.store": { argTypes: [DATE_T, F64], result: F64 }, /** Date.prototype.toString / toDateString / toTimeString and String(date): * V8's local-time formats with the host zone's long name, "Invalid Date" * for an invalid date. Owned (+1) string; never throws. */ diff --git a/packages/compiler/src/ir/callback-specialization.ts b/packages/compiler/src/ir/callback-specialization.ts index 6038ccaa5..4764754af 100644 --- a/packages/compiler/src/ir/callback-specialization.ts +++ b/packages/compiler/src/ir/callback-specialization.ts @@ -130,10 +130,7 @@ export function specializeCallbackCalls( const { written, closureWritten } = rebindings(mod); const copies = new Map(); const candidates = new Map(); - let totalNodes = 0; for (const fn of mod.functions) { - const nodes = countNodes(fn); - totalNodes += nodes; if ( !(fn.ownsPrototype || fn.name.startsWith("%arr.")) || fn.name === mod.entry || @@ -142,10 +139,11 @@ export function specializeCallbackCalls( fn.captures !== undefined || fn.classCaptures?.length || fn.fastRead !== undefined || - fn.speculativeDispatch || - nodes > MAX_FUNCTION_NODES + fn.speculativeDispatch ) continue; + const nodes = countNodes(fn); + if (nodes > MAX_FUNCTION_NODES) continue; const rebound = written.get(fn.name)!; const slots: number[] = []; fn.params.forEach((param, index) => { @@ -165,19 +163,30 @@ export function specializeCallbackCalls( const definitions = new Map>(); const globalIds = new Set(mod.globals?.map((global) => global.id)); const globalWrites = new Map(); + // The sum of countNodes over every function, counted by the walk below. + let totalNodes = 0; for (const fn of mod.functions) { + let nodes = 0; const values = new Map(); const counts = new Map(); const record = (id: string, value: IrExpr | null, weight: number): void => { counts.set(id, (counts.get(id) ?? 0) + weight); - if (value) values.set(id, [...(values.get(id) ?? []), value]); + if (value) { + const list = values.get(id); + if (list === undefined) values.set(id, [value]); + else list.push(value); + } }; everyStmtList(fn.body, { stmt: (stmt) => { + nodes++; if (stmt.kind === "varDecl") record(stmt.localId, stmt.init, stmt.init ? 1 : 0); if (stmt.kind === "assign") { - if (globalIds.has(stmt.localId)) - globalWrites.set(stmt.localId, [...(globalWrites.get(stmt.localId) ?? []), stmt.value]); + if (globalIds.has(stmt.localId)) { + const list = globalWrites.get(stmt.localId); + if (list === undefined) globalWrites.set(stmt.localId, [stmt.value]); + else list.push(stmt.value); + } record(stmt.localId, stmt.value, 1); } if (stmt.kind === "forOf") record(stmt.localId, null, 2); @@ -186,6 +195,7 @@ export function specializeCallbackCalls( return true; }, expr: (expr) => { + nodes++; if (expr.kind === "assignExpr" || expr.kind === "incDec") { record(expr.localId, null, 2); if (globalIds.has(expr.localId)) globalWrites.set(expr.localId, []); @@ -208,6 +218,7 @@ export function specializeCallbackCalls( ) single.set(id, list[0]!); definitions.set(fn.name, single); + totalNodes += Math.min(nodes, MAX_FUNCTION_NODES + 1); } const globalDefinitions = new Map(); for (const global of mod.globals ?? []) diff --git a/packages/compiler/src/ir/capture-values.ts b/packages/compiler/src/ir/capture-values.ts index 779ca1b76..bcc587f1b 100644 --- a/packages/compiler/src/ir/capture-values.ts +++ b/packages/compiler/src/ir/capture-values.ts @@ -538,7 +538,7 @@ export class CaptureFlow { facts.callees.add(e.callee.localId); break; } - return everyExprChild(e, expr, (s) => stmt(s, false)); + return everyExprChild(e, expr, nested); }; // `split` mirrors rewrite(): only statement lists reached through // ordinary nesting from the body are expanded. Switch cases share one @@ -599,8 +599,10 @@ export class CaptureFlow { list(s.body, split) ); } - return everyStmtChild(s, expr, (child) => stmt(child, false)); + return everyStmtChild(s, expr, nested); }; + // Statements nested in expressions and in other statements' heads. + const nested = (s: IrStmt): boolean => stmt(s, false); list(fn.body, true); return facts; } diff --git a/packages/compiler/src/ir/char-unit-locals.test.ts b/packages/compiler/src/ir/char-unit-locals.test.ts index ad16ecebd..9ec25b75a 100644 --- a/packages/compiler/src/ir/char-unit-locals.test.ts +++ b/packages/compiler/src/ir/char-unit-locals.test.ts @@ -96,3 +96,26 @@ console.log(escapes("a b", 1), rebound(" x", 0), wide("ab", 0), against("a", "b expect(fn(after, name)).toBe(fn(before, name)); } }); + +test("charAt tests in place compare charCodeAt", async () => { + const mod = holdCharLocalsAsUnits( + await lower(` +function tests(s: string, i: number): number { + let n = 0; + if (s.charAt(i) === " ") n += 1; + if ("a" <= s.charAt(i) && s.charAt(i) < "n") n += 2; + if (s.charAt(i) === "ab") n += 4; + return n; +} +console.log(tests("a b", 1), tests("abc", 0), tests("", 2)); +`), + ); + // The two-unit literal keeps its string comparison. + expect(reads(fn(mod, "tests"))).toEqual([ + "charCodeAt", + "charCodeAt", + "charCodeAt", + "strEq", + "charAt", + ]); +}); diff --git a/packages/compiler/src/ir/char-unit-locals.ts b/packages/compiler/src/ir/char-unit-locals.ts index beeeffaf3..840b7478f 100644 --- a/packages/compiler/src/ir/char-unit-locals.ts +++ b/packages/compiler/src/ir/char-unit-locals.ts @@ -12,6 +12,8 @@ import { mapExprChildren, mapStmtChildren } from "./traverse.js"; type CharAt = IrExpr & { kind: "strIntrinsic" }; type UnitUse = IrExpr & { kind: "strEq" | "strCmp" }; +const FLIPPED = { "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as const; + /** Character locals held as their code unit. * * `const c = s[i]` (or `s.charAt(i)`) followed only by tests such as @@ -28,7 +30,8 @@ type UnitUse = IrExpr & { kind: "strEq" | "strCmp" }; * * Only immutable unboxed string locals qualify, with one declaration whose * initializer is a one-argument charAt, and with no other mention of the - * local. */ + * local. The same tests of `s.charAt(i)` in place compare + * `s.charCodeAt(i)`, so a loop version sees one kind of read. */ export function holdCharLocalsAsUnits(mod: IrModule): IrModule { let changed = false; const functions = mod.functions.map((fn) => { @@ -43,7 +46,6 @@ function rewriteFunction(fn: IrFunction): IrFunction { const candidates = new Set( fn.locals.filter((l) => !l.mutable && !l.boxed && l.type.kind === "string").map((l) => l.id), ); - if (candidates.size === 0) return fn; // Every mention of a candidate, in any field, against the mentions the // rewrite understands. const mentions = new Map(); @@ -67,7 +69,7 @@ function rewriteFunction(fn: IrFunction): IrFunction { for (const key in node) if (key !== "loc") count(node[key]); } }; - count(fn.body); + if (candidates.size > 0) count(fn.body); const held = new Set(); for (const id of candidates) if ( @@ -76,7 +78,7 @@ function rewriteFunction(fn: IrFunction): IrFunction { mentions.get(id) === understood.get(id) ) held.add(id); - if (held.size === 0) return fn; + let changed = false; const unitRef = (id: string, loc: IrExpr["loc"]): IrExpr => ({ kind: "varRef", @@ -88,38 +90,28 @@ function rewriteFunction(fn: IrFunction): IrFunction { if (e.kind === "strEq" || e.kind === "strCmp") { const use = unitUse(e); if (use && held.has(use.id)) { - const code: IrExpr = { kind: "numLit", value: use.code, type: F64, loc: e.loc }; - const c = unitRef(use.id, e.loc); - if (e.kind === "strEq") - return { - kind: "bin", - op: e.negated ? "!==" : "===", - left: c, - right: code, - type: BOOL, - loc: e.loc, - }; - // The comparison with the local on the left. - const op = use.left - ? e.op - : ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as const)[e.op]; - if (op === ">" || op === ">=") - return { kind: "bin", op, left: c, right: code, type: BOOL, loc: e.loc }; - const negation: IrExpr = { - kind: "bin", - op: op === "<" ? ">=" : ">", - left: c, - right: code, - type: BOOL, - loc: e.loc, + changed = true; + return charUnitTest(e, unitRef(use.id, e.loc), use.code, use.left); + } + // `s.charAt(i)` in place: its unit read, `s.charCodeAt(i)`. + const direct = charAtTest(e); + if (direct) { + changed = true; + const read: IrExpr = { + ...direct.charAt, + method: "charCodeAt", + receiver: rewriteExpr(direct.charAt.receiver), + args: direct.charAt.args.map(rewriteExpr), + type: F64, }; - return { kind: "unary", op: "!", operand: negation, type: BOOL, loc: e.loc }; + return charUnitTest(e, read, direct.code, direct.left); } } return mapExprChildren(e, rewriteExpr, rewriteStmt); }; const rewriteStmt = (s: IrStmt): IrStmt => { - if (s.kind === "varDecl" && held.has(s.localId) && s.init?.kind === "strIntrinsic") + if (s.kind === "varDecl" && held.has(s.localId) && s.init?.kind === "strIntrinsic") { + changed = true; return { ...s, init: { @@ -130,7 +122,9 @@ function rewriteFunction(fn: IrFunction): IrFunction { type: F64, }, }; - if (s.kind === "switch" && s.disc.kind === "varRef" && held.has(s.disc.localId)) + } + if (s.kind === "switch" && s.disc.kind === "varRef" && held.has(s.disc.localId)) { + changed = true; return { ...s, disc: unitRef(s.disc.localId, s.disc.loc), @@ -143,11 +137,14 @@ function rewriteFunction(fn: IrFunction): IrFunction { body: c.body.map(rewriteStmt), })), }; + } return mapStmtChildren(s, rewriteExpr, rewriteStmt); }; + const body = fn.body.map(rewriteStmt); + if (!changed) return fn; return { ...fn, - body: fn.body.map(rewriteStmt), + body, locals: fn.locals.map((l) => (held.has(l.id) ? unitLocal(l) : l)), }; } @@ -179,6 +176,45 @@ function understoodMention(node: Record): string | null { return null; } +/** A one-unit test of a string that reads one character or "" (charAt), + * on its unit `unit`, which reads NaN for "": an equality, or an ordered + * comparison with the unit on the left when `unitLeft`. "" is below every + * one-unit literal, and NaN fails every ordered comparison, so `c < "x"` + * becomes `!(c >= 120)`. */ +function charUnitTest(e: UnitUse, unit: IrExpr, code: number, unitLeft: boolean): IrExpr { + const literal: IrExpr = { kind: "numLit", value: code, type: F64, loc: e.loc }; + if (e.kind === "strEq") + return { + kind: "bin", + op: e.negated ? "!==" : "===", + left: unit, + right: literal, + type: BOOL, + loc: e.loc, + }; + const op = unitLeft ? e.op : FLIPPED[e.op]; + if (op === ">" || op === ">=") + return { kind: "bin", op, left: unit, right: literal, type: BOOL, loc: e.loc }; + const negation: IrExpr = { + kind: "bin", + op: op === "<" ? ">=" : ">", + left: unit, + right: literal, + type: BOOL, + loc: e.loc, + }; + return { kind: "unary", op: "!", operand: negation, type: BOOL, loc: e.loc }; +} + +/** A test between `s.charAt(i)` and a literal of one code unit. */ +function charAtTest(e: UnitUse): { charAt: CharAt; code: number; left: boolean } | null { + const literalRight = e.right.kind === "strLit"; + const literal = literalRight ? e.right : e.left; + const other = literalRight ? e.left : e.right; + if (literal.kind !== "strLit" || literal.value.length !== 1 || !isCharAt(other)) return null; + return { charAt: other, code: literal.value.charCodeAt(0), left: literalRight }; +} + function isCharAt(e: IrExpr): e is CharAt { return e.kind === "strIntrinsic" && e.method === "charAt" && e.args.length === 1; } diff --git a/packages/compiler/src/ir/constant-globals.ts b/packages/compiler/src/ir/constant-globals.ts index f18764223..76b086f40 100644 --- a/packages/compiler/src/ir/constant-globals.ts +++ b/packages/compiler/src/ir/constant-globals.ts @@ -36,7 +36,11 @@ export function foldConstantGlobals(mod: IrModule): IrModule { if (value && (value.kind === "numLit" || value.kind === "boolLit")) values.set(id, value); else values.delete(id); }; - for (const fn of mod.functions) + // Per function, the candidates it reads: the rewrite below visits only the + // functions that read a constant. + const readsByFunction = new Map>(); + for (const fn of mod.functions) { + let read: Set | undefined; everyStmtList(fn.body, { // Every node other than a read that names the binding is a write // (assignments, loop and catch bindings, compound updates); only a @@ -48,16 +52,21 @@ export function foldConstantGlobals(mod: IrModule): IrModule { return true; }, expr: (e) => { - if (e.kind !== "varRef" && "localId" in e && typeof e.localId === "string") - write(e.localId, null); + if (e.kind === "varRef") { + if (candidates.has(e.localId)) { + if (read === undefined) read = new Set(); + read.add(e.localId); + } + } else if ("localId" in e && typeof e.localId === "string") write(e.localId, null); return true; }, }); + if (read !== undefined) readsByFunction.set(fn, read); + } const constants = new Map(); for (const [id, value] of values) if (writes.get(id) === 1) constants.set(id, value); if (constants.size === 0) return mod; - const reads = (e: IrExpr): boolean => !(e.kind === "varRef" && constants.has(e.localId)); const rewriteExpr = (e: IrExpr): IrExpr => { if (e.kind === "varRef") { const value = constants.get(e.localId); @@ -68,7 +77,8 @@ export function foldConstantGlobals(mod: IrModule): IrModule { const rewriteStmt = (s: IrStmt): IrStmt => mapStmtChildren(s, rewriteExpr, rewriteStmt); let changed = false; const functions = mod.functions.map((fn): IrFunction => { - if (everyStmtList(fn.body, { stmt: () => true, expr: reads })) return fn; + const read = readsByFunction.get(fn); + if (read === undefined || ![...read].some((id) => constants.has(id))) return fn; changed = true; return { ...fn, body: fn.body.map(rewriteStmt) }; }); diff --git a/packages/compiler/src/ir/expression-inlining.test.ts b/packages/compiler/src/ir/expression-inlining.test.ts new file mode 100644 index 000000000..ae68c44ea --- /dev/null +++ b/packages/compiler/src/ir/expression-inlining.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "vitest"; +import { inlineExpressionFunctions } from "./expression-inlining.js"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; + +const loc = { file: "expression-inlining.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +const bin = (op: "+" | "*" | ">>>", left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: F64, +}); +const call = (callee: string, args: IrExpr[]): IrExpr => ({ + loc, + kind: "call", + callee, + args, + type: F64, +}); +const local = (id: string, extra: object = {}) => ({ + id, + name: id, + type: F64, + mutable: true, + ...extra, +}); + +// a(i, j) = ((i + j) * (i + j + 1)) >>> 1 +const a: IrFunction = { + loc, + name: "a", + params: [ + { localId: "i", name: "i", type: F64 }, + { localId: "j", name: "j", type: F64 }, + ], + locals: [local("i"), local("j")], + returnType: F64, + body: [ + { + loc, + kind: "return", + value: bin( + ">>>", + bin("*", bin("+", ref("i"), ref("j")), bin("+", bin("+", ref("i"), ref("j")), num(1))), + num(1), + ), + }, + ], +}; + +/** `caller` running `body` in a loop. */ +function caller(body: IrStmt[], extra: object = {}): IrFunction { + return { + loc, + name: "caller", + params: [], + locals: [local("x", extra), local("y"), local("out")], + returnType: VOID, + body: [{ loc, kind: "while", cond: { loc, kind: "boolLit", value: true, type: BOOL }, body }], + }; +} + +const loopBody = (fn: IrFunction): IrStmt[] => (fn.body[0] as IrStmt & { kind: "while" }).body; + +const mod = (fn: IrFunction): IrModule => ({ + irVersion: 17, + sourceFile: "expression-inlining.ts", + entry: "caller", + functions: [a, fn], +}); + +test("calls passing literals and locals take the callee's expression over them", () => { + const result = inlineExpressionFunctions( + mod(caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), num(3)]) }])), + ); + const assign = loopBody(result.functions[1]!)[0] as IrStmt & { kind: "assign" }; + expect(assign.value).toMatchObject({ + kind: "bin", + op: ">>>", + left: { + kind: "bin", + op: "*", + left: { + kind: "bin", + op: "+", + left: { kind: "varRef", localId: "x" }, + right: { kind: "numLit", value: 3 }, + }, + }, + }); + // Every substituted read is a fresh node. + const left = (assign.value as IrExpr & { kind: "bin" }).left as IrExpr & { kind: "bin" }; + const factors = [left.left, (left.right as IrExpr & { kind: "bin" }).left] as (IrExpr & { + kind: "bin"; + })[]; + expect(factors[0]!.left).not.toBe(factors[1]!.left); + expect(result.functions[0]).toBe(a); +}); + +test("calls with other arguments, boxed locals or callees with other bodies keep their call", () => { + const nested = call("a", [call("a", [ref("x"), ref("y")]), num(1)]); + const kept = inlineExpressionFunctions( + mod(caller([{ loc, kind: "assign", localId: "out", value: nested }])), + ); + const outer = (loopBody(kept.functions[1]!)[0] as IrStmt & { kind: "assign" }).value; + // The inner call inlines; the outer one passes a computed argument. + expect(outer).toMatchObject({ + kind: "call", + callee: "a", + args: [{ kind: "bin", op: ">>>" }, { kind: "numLit" }], + }); + const boxed = caller( + [{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }], + { + boxed: true, + }, + ); + expect(inlineExpressionFunctions(mod(boxed)).functions[1]).toBe(boxed); + const twoStatements: IrFunction = { + ...a, + body: [{ loc, kind: "exprStmt", expr: ref("i") }, ...a.body], + }; + const unchanged: IrModule = { + ...mod( + caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }]), + ), + functions: [ + twoStatements, + caller([{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), ref("y")]) }]), + ], + }; + expect(inlineExpressionFunctions(unchanged)).toBe(unchanged); +}); + +test("calls outside loops keep their call", () => { + const outside: IrFunction = { + ...caller([]), + body: [{ loc, kind: "assign", localId: "out", value: call("a", [ref("x"), num(3)]) }], + }; + const m = mod(outside); + expect(inlineExpressionFunctions(m)).toBe(m); +}); diff --git a/packages/compiler/src/ir/expression-inlining.ts b/packages/compiler/src/ir/expression-inlining.ts new file mode 100644 index 000000000..377cfaf3f --- /dev/null +++ b/packages/compiler/src/ir/expression-inlining.ts @@ -0,0 +1,157 @@ +import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { everyExprChild, everyStmtChild, mapExprChildren, transformStmtList } from "./traverse.js"; + +/** Callee bodies above this many nodes keep their call. */ +const MAX_EXPRESSION_NODES = 32; + +/** Number operators that run no other code and cannot throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "**", + "&", + "|", + "^", + "<<", + ">>", + ">>>", +]); + +/** The returned expression of a function whose body is `return ` over + * its number parameters: literals, parameter reads, and the number + * operators of PURE_BINARY, unary `-` and `~`. Null for any other function. */ +function inlinableExpression(fn: IrFunction): IrExpr | null { + if (fn.async || fn.generator || fn.captures !== undefined || fn.classCaptures?.length) + return null; + if (fn.tier === "dynamic" || fn.returnType.kind !== "f64") return null; + if (fn.body.length !== 1) return null; + const only = fn.body[0]!; + if (only.kind !== "return" || only.value === null) return null; + const params = new Set(); + for (const param of fn.params) { + if (param.type.kind !== "f64") return null; + params.add(param.localId); + } + const locals = new Map(fn.locals.map((local) => [local.id, local])); + for (const id of params) { + const local = locals.get(id); + if (local === undefined || local.boxed || local.tdz) return null; + } + let nodes = 0; + const pure = (e: IrExpr): boolean => { + if (++nodes > MAX_EXPRESSION_NODES || e.type.kind !== "f64") return false; + switch (e.kind) { + case "numLit": + return true; + case "varRef": + return params.has(e.localId); + case "bin": + return PURE_BINARY.has(e.op) && pure(e.left) && pure(e.right); + case "unary": + return (e.op === "-" || e.op === "~") && pure(e.operand); + default: + return false; + } + }; + return pure(only.value) ? only.value : null; +} + +/** A call argument that reads the same value wherever the inlined body + * reads it: a number literal or a read of one of the caller's own unboxed + * locals outside the temporal dead zone. The body writes nothing, so every + * read sees the value the call would have passed. */ +function duplicableArgument(e: IrExpr, caller: ReadonlyMap) { + if (e.type.kind !== "f64") return false; + // A literal whose source spelling does not round-trip keeps its call. + if (e.kind === "numLit") return e.spelling === undefined; + if (e.kind !== "varRef") return false; + const local = caller.get(e.localId); + return local !== undefined && !local.boxed && !local.tdz; +} + +/** A fresh copy of `e`: facts downstream are keyed by node identity. */ +function copy(e: IrExpr, substitute: (ref: IrExpr & { kind: "varRef" }) => IrExpr): IrExpr { + if (e.kind === "varRef") return substitute(e); + return mapExprChildren( + { ...e }, + (child) => copy(child, substitute), + (stmt) => stmt, + ); +} + +/** The direct calls of `body` that run inside a loop body or condition. */ +function callsInLoops(body: IrStmt[]): Set { + const found = new Set(); + let depth = 0; + const expr = (e: IrExpr): boolean => { + if (depth > 0 && e.kind === "call") found.add(e); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + const loop = + s.kind === "for" || s.kind === "while" || s.kind === "doWhile" || s.kind === "forOf"; + if (loop) depth++; + everyStmtChild(s, expr, stmt); + if (loop) depth--; + return true; + }; + body.forEach(stmt); + return found; +} + +/** Inlines functions whose body is one number expression over their + * parameters (`function a(i, j) { return (((i + j) * (i + j + 1)) >>> 1) + + * i + 1; }`) at direct call sites that pass literals or reads of the + * caller's locals. + * + * The callee runs no other code, allocates nothing and cannot throw, so + * the call's only effect is its result. Its arguments are reads that + * nothing between them and the callee's reads can change, so substituting + * them gives the same value. The caller's loop and integer analyses then + * see the arithmetic on its own counters (`a(i, j)` inside a counted loop + * over `i` and `j`). Only calls inside loops are inlined: elsewhere the + * integer analyses gain nothing. The function itself stays for its other + * uses. */ +export function inlineExpressionFunctions(mod: IrModule): IrModule { + const members = new Set((mod.classes ?? []).flatMap((cls) => cls.methods ?? [])); + const expressions = new Map(); + for (const fn of mod.functions) { + if (fn.name === mod.entry || members.has(fn.name)) continue; + const value = inlinableExpression(fn); + if (value !== null) expressions.set(fn.name, { fn, value }); + } + if (expressions.size === 0) return mod; + let changed = false; + const functions = mod.functions.map((fn) => { + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const inLoops = callsInLoops(fn.body); + if (inLoops.size === 0) return fn; + let inlined = false; + const body = transformStmtList(fn.body, { + stmt: (s) => s, + expr: (e) => { + if (e.kind !== "call" || e.callee === fn.name || !inLoops.has(e)) return e; + const target = expressions.get(e.callee); + if (target === undefined || target.fn.params.length !== e.args.length) return e; + if (!e.args.every((arg) => duplicableArgument(arg, locals))) return e; + const args = new Map(target.fn.params.map((param, i) => [param.localId, e.args[i]!])); + inlined = true; + return copy(target.value, (ref) => { + const arg = args.get(ref.localId); + if (arg?.kind === "numLit") + return { kind: "numLit", value: arg.value, type: arg.type, loc: ref.loc }; + if (arg?.kind === "varRef") + return { kind: "varRef", localId: arg.localId, type: arg.type, loc: ref.loc }; + return ref; + }); + }, + }); + if (!inlined) return fn; + changed = true; + return { ...fn, body }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index 0739bbb4f..b94dee341 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -523,6 +523,12 @@ test("counting-down recursion proves its parameter from the call-site ranges", ( ); const rising = analyzeInt32Slots(mod([], [up, fn("user", [], [stmt(call("up", [num(-18)]))])])); expect(rising.facts("up").params?.get("d")).toEqual({ min: -18, max: 0 }); + // The same with the caller first, so the fixpoint analyzes it after the + // parameter dropped: the narrowing still sees its argument. + const callerFirst = analyzeInt32Slots( + mod([], [fn("user", [], [stmt(call("up", [num(-18)]))]), up]), + ); + expect(callerFirst.facts("up").params?.get("d")).toEqual({ min: -18, max: 0 }); // A negative start counts down past the smallest int32. const negative = analyzeInt32Slots(mod([], [count, even, odd, user([18, -1])])); diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 06a173c1a..1616a2a77 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -1208,8 +1208,11 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): // Each function's parameter and result writes under its latest analysis: // at the fixpoint, the ranges of every write under the final facts. const lastWrites = new Map>(); - while (queue.length) { - const fn = functions.get(queue.pop()!)!; + // First in, first out: a function whose slots change while others wait + // goes after them, so a caller of many callees is analyzed again once + // for all of their changes, not once for each. + for (let head = 0; head < queue.length; head++) { + const fn = functions.get(queue[head]!)!; queued.delete(fn.name); const params = slots.facts(fn.name).params; if (params) for (const id of params.keys()) reads(`param:${fn.name}\0${id}`, fn.name); @@ -1663,8 +1666,14 @@ function narrowSlotRanges( // The ranges every function was last analyzed under. let analyzed: Map = new Map(proven); let verified: Map = new Map(proven); - // Functions whose recorded writes predate their current facts. + // Functions whose recorded writes predate their current facts: those + // whose parameters rangedParameters proved, and their callers, which + // wrote nothing into those parameters while they had no fact. const dirty = new Set(changed); + for (const name of changed) { + const callers = readersOf(`ret:${name}`); + if (callers) for (const caller of callers) dirty.add(caller); + } // The narrowing operator: a bound moves at most once from its proven // value (a revived result's from int32), so recursion that shifts its // argument (`f(d - 1)`) cannot walk a bound down one step per round. diff --git a/packages/compiler/src/ir/integer-loop-facts.ts b/packages/compiler/src/ir/integer-loop-facts.ts index 8807212dc..d66153b19 100644 --- a/packages/compiler/src/ir/integer-loop-facts.ts +++ b/packages/compiler/src/ir/integer-loop-facts.ts @@ -152,7 +152,7 @@ export function boundedIntegerLoopFacts( e.receiver.kind === "varRef" && stable(e.receiver.localId) ) - return { min: 0, max: Number.MAX_SAFE_INTEGER }; + return BYTES_LENGTH_RANGE; // Every evaluation of an array length lies in its range, so the // condition bounds each pass even when the body resizes the array. if (isArrayLength(e)) return ARRAY_LENGTH_RANGE; @@ -225,6 +225,12 @@ export function boundedIntegerLoopFacts( return result; } +/** Every length, byteLength and byteOffset of a typed array, DataView or + * Buffer: each counts elements or bytes of one allocation, and every + * supported target addresses less than 2^48 bytes. Counters and offsets + * derived from them then stay exact integers far below 2^53. */ +export const BYTES_LENGTH_RANGE: IntegerRange = { min: 0, max: 2 ** 48 }; + /** Every length an array can have: SCR_ARR_MAX_LENGTH (scr_array.c). */ export const ARRAY_LENGTH_RANGE: IntegerRange = { min: 0, max: 4294967295 }; @@ -353,6 +359,13 @@ export function guardedLoopCounterFacts( stable(e.receiver.localId) ) return { min: 0, max: STRING_MAX_UNITS }; + if ( + e.kind === "bytesIntrinsic" && + (e.method === "length" || e.method === "byteLength") && + e.receiver.kind === "varRef" && + stable(e.receiver.localId) + ) + return BYTES_LENGTH_RANGE; // An array length can change on any pass, but never leaves its range, // which is all a ceiling needs. if (isArrayLength(e)) return ARRAY_LENGTH_RANGE; @@ -402,9 +415,84 @@ export function guardedLoopCounterFacts( if (Number.isSafeInteger(start.min) && Number.isSafeInteger(max)) result.set(id, { min: start.min, max }); } + // Companion counters: a binding the loop changes only by positive literal + // increments that no condition bounds (`out[k++] = ...` beside + // `while (i < n)`). A bounded counter that every pass advances before it + // can continue limits the number of passes, and each pass adds at most + // the companion's increments in the body. Increments inside nested loops + // can repeat within a pass, so those companions are not bounded. + let passes = Infinity; + for (const [id, range] of result) { + const hi = ceiling.get(id)!; + const step = guaranteedStep(loop.body, id); + if (step <= 0) continue; + passes = Math.min(passes, Math.floor((hi - range.min) / step) + 1); + } + if (!Number.isSafeInteger(passes) || passes < 0) return result; + const repeated = new Set(); + for (const inner of nested) for (const id of writtenLocals(inner.body)) repeated.add(id); + for (const [id, total] of budget) { + const local = locals.get(id), + start = initial.get(id); + if (result.has(id) || refused.has(id) || repeated.has(id) || ceiling.has(id)) continue; + if (!local || local.boxed || local.tdz || !start) continue; + const max = start.max + passes * total; + if (Number.isSafeInteger(start.min) && Number.isSafeInteger(max)) + result.set(id, { min: start.min, max }); + } return result; } +/** The increments of counter `id` that every pass of `body` makes before it + * can start another pass: those of the leading statements up to the first + * one that can continue the loop or holds a nested region (0 when there is + * none). A pass that leaves the loop earlier starts no further pass, so the + * count bounds the passes that continue. */ +function guaranteedStep(body: readonly IrStmt[], id: string): number { + let step = 0; + for (const s of body) { + if (s.kind === "assign" && s.localId === id) { + const delta = increment(id, s.value); + if (delta === null || delta <= 0) return 0; + step += delta; + continue; + } + if (s.kind === "exprStmt" && s.expr.kind === "incDec" && s.expr.localId === id) { + if (s.expr.op !== "+") return 0; + step += 1; + continue; + } + if (mayContinue(s)) break; + } + return step; +} + +/** Whether `s` can start another pass of the enclosing loop before its + * following statements run: it holds a `continue`, a loop or another + * region whose control flow is not walked here. */ +function mayContinue(s: IrStmt): boolean { + let found = false; + const stmt = (x: IrStmt): boolean => { + if ( + x.kind === "continue" || + x.kind === "for" || + x.kind === "while" || + x.kind === "doWhile" || + x.kind === "forOf" || + x.kind === "tryCatch" || + x.kind === "switch" || + (x.kind === "block" && !!x.labels?.length) + ) { + found = true; + return false; + } + return everyStmtChild(x, expr, stmt); + }; + const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt); + stmt(s); + return found; +} + /** The largest value counter `id` takes in one pass of `body`, which starts * at most at `entry`. The caller has checked that the body changes the * counter only by positive literal increments outside switch, exception and diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 2cca43a00..48be67fbf 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -552,3 +552,71 @@ test("counters stepped beside the tested one run as integers", () => { { startGuarded: true, tripGuarded: true }, ]); }); + +/** `counted(limit)` with the body `x = ((i + k) * (i + k + 1)) >>> 1` + * inside a nested `for (let j = 0; j < 4; j++)` loop. */ +function productLoop(limit: IrExpr, start: IrExpr = number(0)): IrStmt & { kind: "for" } { + const s = counted(limit); + if (s.init?.kind === "varDecl") s.init.init = start; + const sum = (extra: number): IrExpr => { + const base: IrExpr = { kind: "bin", op: "+", left: ref("i"), right: ref("k"), type: F64, loc }; + return extra === 0 + ? base + : { kind: "bin", op: "+", left: base, right: number(extra), type: F64, loc }; + }; + const product: IrExpr = { kind: "bin", op: "*", left: sum(0), right: sum(1), type: F64, loc }; + const inner = counted(number(4)); + if (inner.init?.kind === "varDecl") inner.init.localId = "j"; + if (inner.cond?.kind === "bin") inner.cond.left = ref("j"); + if (inner.update?.kind === "assign") { + inner.update.localId = "j"; + if (inner.update.value.kind === "bin") inner.update.value.left = ref("j"); + } + inner.body = [ + { + kind: "assign", + localId: "x", + value: { kind: "bin", op: ">>>", left: product, right: number(1), type: F64, loc }, + loc, + }, + ]; + s.body = [inner]; + return s; +} + +test("guarded counted loops whose products the counter bound proves exact may hold nested loops", () => { + const scope = new Map(locals); + for (const id of ["limit", "start", "k", "x", "j"]) + scope.set(id, { id, name: id, type: F64, mutable: true }); + const tight = matchIntegerCountedForLoop(productLoop(ref("limit"), ref("start")), scope); + expect(tight).toMatchObject({ + guarded: true, + limitGuarded: true, + startGuarded: true, + startBound: 2 ** 25, + guardLimit: 2 ** 25, + range: { min: -(2 ** 25), max: 2 ** 25 - 1 }, + }); + // A literal trip count past the bound keeps the safe-integer bound, and + // a guarded loop under that bound holds no nested loop. + expect(matchIntegerCountedForLoop(productLoop(number(1e9), ref("start")), scope)).toBeNull(); + const plain = counted(ref("limit")); + plain.body = [productLoop(number(4)).body[0]!]; + const inner = plain.body[0] as IrStmt & { kind: "for" }; + inner.body = [ + { + kind: "assign", + localId: "x", + value: { kind: "bin", op: "|", left: ref("i"), right: number(0), type: F64, loc }, + loc, + }, + ]; + expect(matchIntegerCountedForLoop(plain, scope)).toBeNull(); + // Without a nested loop the product bound still applies to the limit. + const flat = productLoop(ref("limit"), ref("start")); + flat.body = (flat.body[0] as IrStmt & { kind: "for" }).body; + expect(matchIntegerCountedForLoop(flat, scope)).toMatchObject({ + startBound: 2 ** 25, + guardLimit: 2 ** 25, + }); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 2f7d2bea6..7cc49b7a0 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -24,8 +24,12 @@ export interface IntegerCountedForLoop { /** The limit needs the run-time `guardLimit` test (part of `guarded`). */ limitGuarded: boolean; /** The start has no proven integer range: the versioned loop also tests - * that it is an exact integer (not -0) of at most 2^53 in magnitude. */ + * that it is an exact integer (not -0) of at most `startBound` in + * magnitude. */ startGuarded: boolean; + /** The magnitude the guarded start is tested against: 2^53 - 1, or + * PRODUCT_COUNTER_BOUND when the body multiplies sums of the counter. */ + startBound: number; guardLimit: number; range: IntegerRange; /** Counters declared and stepped beside this one that the condition does @@ -50,6 +54,64 @@ export interface IntegerLoopFollower { range: IntegerRange; } +/** The counter magnitude below which a product of two sums of counters and + * small literals stays exact in a double: `(i + j) * (i + j + 1)` with both + * counters within 2^25 is below 2^52. A guarded loop whose body multiplies + * such sums for a bitwise operator tests its counter against this bound, so + * the product's integer form is proven exact. */ +export const PRODUCT_COUNTER_BOUND = 2 ** 25; + +/** Whether `e` is a sum or difference of locals and safe integer literals + * of magnitude at most 2^20, and whether it reads `counter`. */ +function localSum(e: IrExpr, counter: string): { sum: boolean; reads: boolean } { + let reads = false; + const sum = (x: IrExpr): boolean => { + if (x.kind === "varRef") { + if (x.localId === counter) reads = true; + return true; + } + if (x.kind === "numLit") return Number.isSafeInteger(x.value) && Math.abs(x.value) <= 2 ** 20; + return x.kind === "bin" && (x.op === "+" || x.op === "-") && sum(x.left) && sum(x.right); + }; + return { sum: sum(e), reads }; +} + +/** Whether a bitwise operand `e` multiplies two sums of locals and + * literals, one of which reads `counter` (`((i + j) * (i + j + 1)) >>> 1`). */ +function multipliesCounterSums(e: IrExpr, counter: string): boolean { + if (e.kind !== "bin") return false; + if (e.op === "*") { + const left = localSum(e.left, counter), + right = localSum(e.right, counter); + return left.sum && right.sum && (left.reads || right.reads); + } + return ( + (e.op === "+" || e.op === "-") && + (multipliesCounterSums(e.left, counter) || multipliesCounterSums(e.right, counter)) + ); +} + +const BITWISE = new Set(["&", "|", "^", "<<", ">>", ">>>"]); + +/** Whether `body`, nested loops included, takes a bitwise operator of a + * product that multipliesCounterSums matches. */ +function bodyMultipliesCounter(body: IrStmt[], counter: string): boolean { + let found = false; + everyStmtList(body, { + stmt: () => !found, + expr: (e) => { + if ( + e.kind === "bin" && + BITWISE.has(e.op) && + (multipliesCounterSums(e.left, counter) || multipliesCounterSums(e.right, counter)) + ) + found = true; + return !found; + }, + }); + return found; +} + /** The bound of the run-time follower test. */ export const FOLLOWER_GUARD_LIMIT = 2 ** 52; /** The magnitude a follower that passed the test can reach. The test's four @@ -171,6 +233,23 @@ export function matchIntegerCountedForLoop( // The condition tests the driving counter; every other declaration // follows it. const driver = declared(cond.left) ?? declared(cond.right); + // A body that multiplies sums of the driver tests it against the bound + // under which those products are exact. A limit proven past that bound + // (a literal trip count) keeps the safe-integer bound instead. + const limitSide = driver !== null && declared(cond.left) === driver ? cond.right : cond.left; + const knownLimit = + limitSide.kind === "numLit" + ? { min: limitSide.value, max: limitSide.value } + : ranges.get(limitSide); + const driverBound = + driver !== null && + !( + knownLimit && + Math.max(Math.abs(knownLimit.min), Math.abs(knownLimit.max)) > PRODUCT_COUNTER_BOUND + ) && + bodyMultipliesCounter(stmt.body, driver) + ? PRODUCT_COUNTER_BOUND + : Number.MAX_SAFE_INTEGER; const counterIds = decls.map((d) => (d.kind === "varDecl" ? d.localId : "")); const stepOf = (id: string): number | null => { const steps = updates.map((u) => constantStep(u, id)).filter((v) => v !== null); @@ -191,7 +270,8 @@ export function matchIntegerCountedForLoop( ? { min: start.value, max: start.value } : ranges.get(start); const guarded = !known && start.kind !== "numLit" && start.type.kind === "f64"; - const range = guarded ? { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } : known; + const bound = id === driver ? driverBound : Number.MAX_SAFE_INTEGER; + const range = guarded ? { min: -bound, max: bound } : known; const step = stepOf(id); if ( !range || @@ -248,7 +328,9 @@ export function matchIntegerCountedForLoop( ); if (!stableLoopValue(limit, locals, stmt.body, true, local.id, stableLength)) return null; const inclusive = op === "<=" || op === ">="; - const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0); + const limitBound = driverBound === Number.MAX_SAFE_INTEGER ? 2 ** 53 : driverBound; + const guardLimit = + step > 0 ? limitBound - (inclusive ? 1 : 0) : -limitBound + (inclusive ? 1 : 0); const boundRange = limit.kind === "numLit" ? { min: limit.value, max: limit.value } : ranges.get(limit); const limitGuarded = @@ -264,7 +346,9 @@ export function matchIntegerCountedForLoop( Number.MAX_SAFE_INTEGER, boundRange ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) - : Number.MAX_SAFE_INTEGER, + : limitGuarded + ? Math.ceil(guardLimit) - (inclusive ? 0 : 1) + : Number.MAX_SAFE_INTEGER, ), ), } @@ -275,7 +359,9 @@ export function matchIntegerCountedForLoop( -Number.MAX_SAFE_INTEGER, boundRange ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) - : -Number.MAX_SAFE_INTEGER, + : limitGuarded + ? Math.floor(guardLimit) + (inclusive ? 0 : 1) + : -Number.MAX_SAFE_INTEGER, ), ), max: startRange.max, @@ -366,9 +452,15 @@ export function matchIntegerCountedForLoop( const bodyStmt = (s: IrStmt): boolean => { if (s.kind === "bytesSet" && counter(s.index)) useful = true; if (!guarded && s.kind === "arraySet" && counter(s.index)) useful = true; + // A guarded loop's copy duplicates nested loops too, so only a body + // whose counter products the bound proves exact may hold a nested + // `for` loop; the node budget counts its body. if ( guarded && - (s.kind === "for" || s.kind === "forOf" || s.kind === "while" || s.kind === "doWhile") + (s.kind === "forOf" || + s.kind === "while" || + s.kind === "doWhile" || + (s.kind === "for" && driverBound === Number.MAX_SAFE_INTEGER)) ) return false; return (!guarded || ++cost <= 80) && everyStmtChild(s, expr, bodyStmt); @@ -383,6 +475,7 @@ export function matchIntegerCountedForLoop( guarded, limitGuarded, startGuarded, + startBound: driverBound, guardLimit, range, followers, diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 5ae04d28f..318ff2c09 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -828,6 +828,72 @@ test("a while condition on a string's length bounds a counter, and guarded incre expect(run((inside) => [observe(inside), bump, rebound]).after).toBeNull(); }); +test("a while counter every pass advances bounds the companions the loop increments", () => { + // pos = 0; k = 0; while (pos < text.length) { pos++; observe(k); if (unknown === 1) k++; k++; } observe(k) + const text: IrExpr = { loc, kind: "varRef", localId: "text", type: STRING }; + const length: IrExpr = { + loc, + kind: "strIntrinsic", + method: "length", + receiver: text, + args: [], + type: F64, + }; + const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value }); + const bump = (localId: string): IrStmt => ({ + loc, + kind: "exprStmt", + expr: { loc, kind: "incDec", op: "+", prefix: false, localId, type: F64 }, + }); + const maybe = (then: IrStmt[]): IrStmt => ({ + loc, + kind: "if", + cond: condition("===", ref("unknown"), num(1)), + then, + else_: null, + }); + const run = (body: (inside: IrExpr) => IrStmt[]) => { + const inside = ref("k"), + after = ref("k"); + const loop: IrStmt = { + loc, + kind: "while", + cond: condition("<", ref("pos"), length), + body: body(inside), + }; + const f = fn([set("pos", num(0)), set("k", num(0)), loop, observe(after)]); + f.locals.push( + { id: "pos", name: "pos", type: F64, mutable: true }, + { id: "k", name: "k", type: F64, mutable: true }, + { id: "text", name: "text", type: STRING, mutable: false }, + ); + const facts = analyzeIntegerRangeFacts(f); + return { + inside: facts.ranges.get(inside), + after: facts.ranges.get(after), + loop: facts.loopRanges.get(loop)?.get("k"), + }; + }; + // At most 2^31 - 1 passes, each adding at most 2. + const bounded = { min: 0, max: 4294967294 }; + expect(run((inside) => [bump("pos"), observe(inside), maybe([bump("k")]), bump("k")])).toEqual({ + inside: bounded, + after: bounded, + loop: bounded, + }); + // A pass that can continue before advancing the counter, or an increment a + // nested loop repeats, bounds nothing. + const skip: IrStmt = maybe([{ loc, kind: "continue" }]); + expect(run((inside) => [skip, bump("pos"), observe(inside), bump("k")]).after).toBeNull(); + const repeat: IrStmt = { + loc, + kind: "while", + cond: condition("===", ref("unknown"), num(1)), + body: [bump("k")], + }; + expect(run((inside) => [bump("pos"), observe(inside), repeat]).after).toBeNull(); +}); + test("a nested while loop may advance a counter its own condition bounds", () => { // while (pos < text.length) { observe(pos); while (pos < text.length && unknown === 1) pos++; pos += 2; } const text: IrExpr = { loc, kind: "varRef", localId: "text", type: STRING }; @@ -1323,7 +1389,7 @@ test("a for loop bounds cursors it resets and copies, and their writes keep the } }); -test("a cursor of a loop over a typed array keeps its writes exact up to 2^53 - 1", () => { +test("a cursor of a loop over a typed array keeps its writes within the largest length", () => { // for (x = 0; x < bytes.length; x++) changes = changes + 1; const bytes: IrExpr = { loc, @@ -1355,7 +1421,7 @@ test("a cursor of a loop over a typed array keeps its writes exact up to 2^53 - { id: "changes", name: "changes", type: F64, mutable: true }, { id: "bytes", name: "bytes", type: { kind: "bytes", elem: "u8" }, mutable: false }, ); - // The sum of the header range passes 2^53 - 1; the write takes the loop - // range instead. - expect(analyzeIntegerRanges(f).get(counted)).toEqual({ min: 0, max: Number.MAX_SAFE_INTEGER }); + // The sum of the header range passes the largest typed array length; the + // write takes the loop range instead. + expect(analyzeIntegerRanges(f).get(counted)).toEqual({ min: 1, max: 2 ** 48 }); }); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 683ceab75..1909040c4 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -2,6 +2,7 @@ import type { IrExpr, IrFunction, IrStmt } from "./ir.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "./traverse.js"; import { byteNumberAccess, byteNumberRange } from "./byte-numbers.js"; import { + BYTES_LENGTH_RANGE, boundedIntegerLoopFacts, guardedLoopCounterFacts, integerPathFallsThrough, @@ -821,7 +822,7 @@ export function analyzeIntegerRangeFacts( const numeric = byteNumberAccess(e); if (numeric && !numeric.write) range = byteNumberRange(numeric); else if (e.method === "length" || e.method === "byteLength" || e.method === "byteOffset") - range = { min: 0, max: Number.MAX_SAFE_INTEGER }; + range = BYTES_LENGTH_RANGE; else if (e.method === "get" && e.invalidNaN !== true && e.receiver.type.kind === "bytes") { const elem = e.receiver.type.elem; if (elem === "u8" || elem === "u8c") range = { min: 0, max: 255 }; @@ -1075,6 +1076,19 @@ export function analyzeIntegerRangeFacts( // inside and after the loop. const guarded = s.kind === "while" ? guardedLoopCounterFacts(s, loopLocals, facts) : noInvariants; + // The same bounds hold on entry and at every write in the loop, + // which is what a proven integer loop needs (loopRanges). A loop + // analyzed again keeps the bindings every analysis bounds. + if (s.kind === "while") { + const previous = loopRanges.get(s); + if (!previous) loopRanges.set(s, new Map(guarded)); + else + for (const [id, range] of previous) { + const other = guarded.get(id); + if (other) previous.set(id, union(range, other)); + else previous.delete(id); + } + } const loop = new Map(facts); havocWith(loop, [s], loop, guarded); for (const [id, range] of guarded) set(loop, id, range); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b119a17ea..4eb358cd5 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -855,9 +855,8 @@ export function funcOf(params: IrType[], ret: IrType): IrType { * promises nullable-only; declared maybe-promise unions are admitted by * maybePromiseUnionArmsOk. */ export function unionContainerArmsOk(arms: IrType[]): boolean { - return ( - arms.every((a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b))) && - dateUnionArmsOk(arms) + return arms.every( + (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)), ); } @@ -868,13 +867,7 @@ export function unionContainerArmsOk(arms: IrType[]): boolean { * non-thenable path). Data arms must not be thenable; the type mapper * checks that against the checker types before minting such a union. */ export function maybePromiseUnionArmsOk(arms: IrType[]): boolean { - return arms.filter((a) => a.kind === "promise").length <= 1 && dateUnionArmsOk(arms); -} - -/** A Date arm has a union home beside undefined and null arms only: no - * narrowing contract tells it from other data arms yet. */ -function dateUnionArmsOk(arms: IrType[]): boolean { - return arms.every((a, i) => a.kind !== "date" || arms.every((b, j) => j === i || isUnitType(b))); + return arms.filter((a) => a.kind === "promise").length <= 1; } /** Canonical, injective text form of an IrType — the building block of @@ -1792,16 +1785,32 @@ export function recordTextCodecClass(shape: IrRecordShape): "TextEncoder" | "Tex return null; } -/** The codec class of a TextEncoder or TextDecoder record type, else null. - * Such a record crosses an `unknown` slot as a native handle that holds the - * record itself (scr_text_codec_dyn.c), never as a copy or a field view. */ +/** The class of a record that crosses checked-dynamic slots as a native + * handle: the codecs, and a StringDecoder (type-mapper.ts: its canonical + * encoding and the packing of its pending bytes). */ +export function recordHandleCodecClass( + shape: IrRecordShape, +): "TextEncoder" | "TextDecoder" | "StringDecoder" | null { + const codec = recordTextCodecClass(shape); + if (codec !== null) return codec; + return shape.fields.length === 2 && + shape.fields[0]!.name === "%enc" && + shape.fields[1]!.name === "%pending" + ? "StringDecoder" + : null; +} + +/** The codec class of a TextEncoder, TextDecoder or StringDecoder record + * type, else null. Such a record crosses an `unknown` slot as a native + * handle that holds the record itself (scr_text_codec_dyn.c), never as a + * copy or a field view. */ export function textCodecRecordClass( t: IrType, getRecord: (shapeId: string) => IrRecordShape | undefined, -): "TextEncoder" | "TextDecoder" | null { +): "TextEncoder" | "TextDecoder" | "StringDecoder" | null { if (t.kind !== "record") return null; const shape = getRecord(t.shapeId); - return shape ? recordTextCodecClass(shape) : null; + return shape ? recordHandleCodecClass(shape) : null; } /** Object-literal ACCESSOR properties (`{ get x() {...}, set x(v) {...} }`) diff --git a/packages/compiler/src/ir/js-ic.test.ts b/packages/compiler/src/ir/js-ic.test.ts index f71a1e9db..f5391b83e 100644 --- a/packages/compiler/src/ir/js-ic.test.ts +++ b/packages/compiler/src/ir/js-ic.test.ts @@ -126,6 +126,140 @@ test("property helpers whose class branches only test capsules are cache sites", expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "set", key: "y" }); }); +test("bags converted between dyn and jsval are still the receiver", () => { + // A program that declares a class returns its bags as jsvals, and the + // helpers read them through js.toDyn. + const toDyn = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "js.toDyn", + args: [value], + type: DYN, + loc, + }); + const fromDyn = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "js.fromDyn", + args: [value], + type: { kind: "jsval" }, + loc, + }); + const jsvalBag = helper( + "%dyn.class.readPropertiesOrEmpty.js", + ["v"], + [capsuleBranch("v"), ret(fromDyn(ref("v")))], + ); + const staticBag = helper("%dyn.class.staticProperties", ["v"], [ret(ref("v"))]); + const getter = getHelper( + "%dyn.class.property.0", + "x", + [capsuleBranch("p.0")], + call(staticBag.name, toDyn(call(jsvalBag.name, ref("p.0")))), + ); + const setter = setHelper("%dyn.class.property.1", "y"); + const write = setter.body[0] as Extract; + const keySet = write.expr as Extract; + keySet.args[0] = toDyn(call(jsvalBag.name, ref("p.0"))); + // A converted value that is not the receiver stays a helper call. + const foreign = getHelper( + "%dyn.class.property.2", + "x", + [], + toDyn(fromDyn({ kind: "dynObjLit", type: DYN, loc } as IrExpr)), + ); + const sites = sitesOf(jsvalBag, staticBag, getter, setter, foreign); + expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "get", key: "x" }); + expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "set", key: "y" }); + expect(sites.site("%dyn.class.property.2")).toBeNull(); +}); + +test("a bag helper may first rebind its parameter to a bag of it", () => { + // Programs with subclasses normalize the value before the class branches. + const normalize = helper("%dyn.class.normalize", ["v"], [capsuleBranch("v"), ret(ref("v"))]); + const rebind = (value: IrExpr): IrStmt => ({ kind: "assign", localId: "v", value, loc }); + const normalized = helper( + "%dyn.class.readPropertiesOrEmpty.normalized", + ["v"], + [rebind(call(normalize.name, ref("v"))), capsuleBranch("v"), ret(ref("v"))], + ); + const replaced = helper( + "%dyn.class.readPropertiesOrEmpty.replaced", + ["v"], + [rebind({ kind: "dynObjLit", type: DYN, loc } as IrExpr), ret(ref("v"))], + ); + const sites = sitesOf( + normalize, + normalized, + replaced, + getHelper("%dyn.class.property.0", "x", [], call(normalized.name, ref("p.0"))), + getHelper("%dyn.class.property.1", "x", [], call(replaced.name, ref("p.0"))), + // The property helper itself normalizes its receiver first, too. + getHelper("%dyn.class.property.2", "x", [ + { kind: "assign", localId: "p.0", value: call(normalize.name, ref("p.0")), loc }, + capsuleBranch("p.0"), + ]), + ); + expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "get", key: "x" }); + expect(sites.site("%dyn.class.property.1")).toBeNull(); + expect(sites.site("%dyn.class.property.2")).toEqual({ kind: "get", key: "x" }); +}); + +test("keyed helpers may test capsules between the key's coercion and the read", () => { + const read = (object: IrExpr, key: string): IrExpr => ({ + kind: "libCall", + fn: "js.fromDyn", + args: [{ kind: "dynKeyGet", value: object, key: ref(key), type: DYN, loc }], + type: { kind: "jsval" }, + loc, + }); + const guard = (test: "symbol" | "nullish", tested: string, object: IrExpr): IrStmt => ({ + kind: "if", + cond: { kind: "dynTest", test, value: ref(tested), type: BOOL, loc }, + then: [ret(read(object, "p.key"))], + else_: null, + loc, + }); + const keyed = (name: string, between: IrStmt[]): IrFunction => + helper( + name, + ["p.0", "p.key"], + [ + capsuleBranch("p.0"), + guard("symbol", "p.key", call(identityBag.name, ref("p.0"))), + guard("nullish", "p.0", ref("p.0")), + { + kind: "varDecl", + localId: "key.string", + init: { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [ref("p.key")], + type: STRING, + loc, + }, + loc, + }, + ...between, + ret(read(call(identityBag.name, ref("p.0")), "key.string")), + ], + ["key.string"], + ); + const errorBranch: IrStmt = { + kind: "if", + cond: { kind: "dynTest", test: "error", value: ref("p.0"), type: BOOL, loc }, + then: [ret(ref("p.0"))], + else_: null, + loc, + }; + const sites = sitesOf( + keyed("%dyn.class.computed.0", []), + keyed("%dyn.class.computed.1", [capsuleBranch("p.0"), capsuleBranch("p.0", "dyn.classIs")]), + keyed("%dyn.class.computed.2", [errorBranch]), + ); + expect(sites.site("%dyn.class.computed.0")).toEqual({ kind: "keyed" }); + expect(sites.site("%dyn.class.computed.1")).toEqual({ kind: "keyed" }); + expect(sites.site("%dyn.class.computed.2")).toBeNull(); +}); + test("any other prefix or tail keeps the helper call", () => { const errorBranch: IrStmt = { kind: "if", @@ -237,3 +371,57 @@ test("bag helpers that exit early for values no class branch matches are still t expect(sites.site("%dyn.class.property.2")).toBeNull(); expect(sites.site("%dyn.class.property.3")).toBeNull(); }); + +test("helpers that answer plain values first with the same operation are cache sites", () => { + const negated = (test: "classCapsule" | "funcKind"): IrExpr => ({ + kind: "dynTest", + test, + negated: true, + value: ref("p.0"), + type: BOOL, + loc, + }); + const exit = (body: IrStmt[], test: IrExpr = negated("classCapsule")): IrStmt => ({ + kind: "if", + cond: test, + then: [{ kind: "if", cond: negated("funcKind"), then: body, else_: null, loc }], + else_: null, + loc, + }); + const store = (key: string): IrStmt[] => [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ref("p.0"), str(key), ref("p.1")], + type: VOID, + loc, + }, + loc, + }, + ret(null), + ]; + const load = (key: string): IrStmt[] => [ + ret({ kind: "dynKeyGet", value: ref("p.0"), key: str(key), type: DYN, loc }), + ]; + const sites = sitesOf( + setHelper("%dyn.class.property.0", "x", [exit(store("x")), capsuleBranch("p.0")]), + getHelper("%dyn.class.property.1", "x", [exit(load("x")), capsuleBranch("p.0")]), + // An exit for another key, or one plain values do not take. + setHelper("%dyn.class.property.2", "x", [exit(store("y"))]), + setHelper("%dyn.class.property.3", "x", [ + exit(store("x"), { + kind: "dynTest", + test: "classCapsule", + value: ref("p.0"), + type: BOOL, + loc, + }), + ]), + ); + expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "set", key: "x" }); + expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "get", key: "x" }); + expect(sites.site("%dyn.class.property.2")).toBeNull(); + expect(sites.site("%dyn.class.property.3")).toBeNull(); +}); diff --git a/packages/compiler/src/ir/js-ic.ts b/packages/compiler/src/ir/js-ic.ts index 752316db3..48019636a 100644 --- a/packages/compiler/src/ir/js-ic.ts +++ b/packages/compiler/src/ir/js-ic.ts @@ -61,13 +61,68 @@ export class JsIcSites { if (fn.params.some((p) => p.type.kind !== "dyn")) return null; const receiver = fn.params[0]?.localId; if (receiver === undefined) return null; + const second = fn.params[1]?.localId; + const exits: JsIcSite[] = []; let i = 0; - while (i < fn.body.length && this.capsuleBranch(fn.body[i]!, receiver)) i++; + for (; i < fn.body.length; i++) { + const s = fn.body[i]!; + if (this.capsuleBranch(s, receiver) || this.rebinding(s, receiver)) continue; + const exit = this.plainExit(s, receiver, second); + if (exit === null) break; + exits.push(exit); + } const tail = fn.body.slice(i); - if (fn.params.length === 1) return this.getTail(tail, receiver); - if (fn.params.length !== 2) return null; - const second = fn.params[1]!.localId; - return this.setTail(tail, receiver, second) ?? this.keyedTail(tail, receiver, second); + let site: JsIcSite | null; + if (fn.params.length === 1) site = this.getTail(tail, receiver); + else if (fn.params.length !== 2 || second === undefined) return null; + else site = this.setTail(tail, receiver, second) ?? this.keyedTail(tail, receiver, second); + if (site === null || site.kind === "keyed") return exits.length === 0 ? site : null; + // An exit answers plain values with the operation the site caches. + return exits.every((exit) => exit.kind === site.kind && exit.key === site.key) ? site : null; + } + + /** `if (!classCapsule(receiver)) { [if (!funcKind(receiver))] OP }`, no + * else: an exit that runs the helper's generic operation directly on a + * value no class table matches. OP is `return [js.fromDyn](dynKeyGet(R, + * K))` or `dyn.keySet(R, K, value); return`, with R the receiver (or a + * bag of it). */ + private plainExit(s: IrStmt, receiver: string, value: string | undefined): JsIcSite | null { + const negated = (e: IrExpr, test: "classCapsule" | "funcKind"): boolean => + e.kind === "dynTest" && e.test === test && e.negated === true && isVar(e.value, receiver); + if (s.kind !== "if" || s.else_ !== null || !negated(s.cond, "classCapsule")) return null; + let body = s.then; + const inner = body[0]; + if ( + body.length === 1 && + inner?.kind === "if" && + inner.else_ === null && + negated(inner.cond, "funcKind") + ) + body = inner.then; + const [first, ret] = body; + if (body.length === 1 && first?.kind === "return") { + const read = unwrapFromDyn(first.value); + if ( + read?.kind !== "dynKeyGet" || + read.key.kind !== "strLit" || + !this.bagOf(read.value, receiver) + ) + return null; + return { kind: "get", key: read.key.value }; + } + if ( + body.length !== 2 || + first?.kind !== "exprStmt" || + ret?.kind !== "return" || + ret.value !== null + ) + return null; + const call = first.expr; + if (call.kind !== "libCall" || call.fn !== "dyn.keySet" || call.args.length !== 3) return null; + const [bag, key, stored] = call.args; + if (!bag || !this.bagOf(bag, receiver) || key?.kind !== "strLit") return null; + if (value === undefined || !isVar(stored, value)) return null; + return { kind: "set", key: key.value }; } /** `varDecl bag = BAG(receiver); return [js.fromDyn](bag === receiver ? @@ -109,10 +164,14 @@ export class JsIcSites { /** `if (typeof key === "symbol") return dynKeyGet(BAG(receiver), key); * if (receiver == null) return dynKeyGet(receiver, key); * const s = String(key); return dynKeyGet(BAG(receiver), s)`, each read - * possibly under js.fromDyn. */ + * possibly under js.fromDyn. Capsule branches may precede the final read: + * a program that declares classes looks the string key up in each class's + * key table there. */ private keyedTail(tail: IrStmt[], receiver: string, key: string): JsIcSite | null { - const [symbol, nullish, decl, ret] = tail; - if (tail.length !== 4) return null; + const [symbol, nullish, decl] = tail; + const ret = tail[tail.length - 1]; + if (tail.length < 4) return null; + if (!tail.slice(3, -1).every((s) => this.capsuleBranch(s, receiver))) return null; if (!this.guard(symbol, "symbol", key, (read) => this.bagOf(read, receiver), key)) return null; if (!this.guard(nullish, "nullish", receiver, (read) => isVar(read, receiver), key)) return null; @@ -159,9 +218,12 @@ export class JsIcSites { } /** Whether `e` is the receiver itself for every ordinary object or array: - * the receiver, or a chain of property-bag helpers applied to it. */ + * the receiver, or a chain of property-bag helpers applied to it. A + * conversion between the dyn and jsval forms keeps the object it holds. */ private bagOf(e: IrExpr, receiver: string): boolean { if (isVar(e, receiver)) return true; + if (e.kind === "libCall" && (e.fn === "js.toDyn" || e.fn === "js.fromDyn")) + return e.args.length === 1 && this.bagOf(e.args[0]!, receiver); return ( e.kind === "call" && e.args.length === 1 && @@ -182,6 +244,13 @@ export class JsIcSites { return ret.kind === "return" && ret.value !== null && this.bagOf(ret.value, param); } + /** `param = `, such as the subclass normalization + * `value = %dyn.class.normalize(value)`, which leaves an ordinary object + * or array in `param`. */ + private rebinding(s: IrStmt, param: string): boolean { + return s.kind === "assign" && s.localId === param && this.bagOf(s.value, param); + } + /** A property-bag helper that returns its argument unless a capsule * branch is taken. */ private identityHelper(name: string): boolean { @@ -204,7 +273,10 @@ export class JsIcSites { identity = fn.body .slice(0, -1) - .every((s) => this.capsuleBranch(s, param) || this.kindExit(s, param)) && + .every( + (s) => + this.capsuleBranch(s, param) || this.kindExit(s, param) || this.rebinding(s, param), + ) && last?.kind === "return" && last.value !== null && this.bagOf(last.value, param); diff --git a/packages/compiler/src/ir/receiver-readers.ts b/packages/compiler/src/ir/receiver-readers.ts index 3d0e6f35d..f5270aa7d 100644 --- a/packages/compiler/src/ir/receiver-readers.ts +++ b/packages/compiler/src/ir/receiver-readers.ts @@ -1,6 +1,6 @@ import { isBorrowSafeMath } from "./analysis.js"; import type { IrExpr, IrFunction, IrLibFn, IrStmt } from "./ir.js"; -import { everyStmtList } from "./traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex } from "./node-index.js"; /** Expression kinds that run no user code of their own and never consult * the ambient receiver stack. Calls are admitted here and resolved through @@ -85,6 +85,25 @@ const RECEIVER_FREE_STMTS = new Set([ "runtimeFence", ]); +/** The kinds whose nodes the readers walk answers true for without looking + * at them: every receiver-free statement, and the receiver-free expressions + * other than calls, library calls, string conversions and data intrinsics. */ +const TRIVIALLY_RECEIVER_FREE = new Set([ + ...RECEIVER_FREE_STMTS, + ...[...RECEIVER_FREE_EXPRS].filter( + (kind) => + kind !== "call" && + kind !== "new" && + kind !== "virtualCall" && + kind !== "libCall" && + kind !== "toString" && + kind !== "arrIntrinsic" && + kind !== "strIntrinsic" && + kind !== "mapIntrinsic" && + kind !== "setIntrinsic", + ), +]); + /** Runtime library calls beyond scalar Math that only compute or build and * park a native error: the f64[] folds behind Math.max/min(...xs), * `new Error(message)` (scr_error_new), and the checked-read TypeError @@ -133,7 +152,11 @@ function opaqueOperand(e: IrExpr): boolean { export class AmbientReceiverReaders { private readers: Set | null = null; - constructor(private readonly functions: ReadonlyMap) {} + constructor( + private readonly functions: ReadonlyMap, + /** The module's node index, read instead of walking the bodies it holds. */ + private readonly nodes?: IrNodeIndex, + ) {} ignores(fn: IrFunction): boolean { if (fn.async || fn.generator) return false; @@ -153,6 +176,7 @@ export class AmbientReceiverReaders { } const readers = new Set(); const callers = new Map(); + const scan = this.nodes?.scanExcept(TRIVIALLY_RECEIVER_FREE); for (const fn of this.functions.values()) { const callees = new Set(); let reads = fn.async === true || fn.generator !== undefined; @@ -161,32 +185,36 @@ export class AmbientReceiverReaders { callees.add(name); return true; }; + const expr = (expr: IrExpr): boolean => { + if (!RECEIVER_FREE_EXPRS.has(expr.kind)) return false; + switch (expr.kind) { + case "call": + return edge(expr.callee); + case "new": + return edge(`%${expr.className}.constructor`); + case "virtualCall": { + // Any class's override may run: admit only when every + // same-named method is itself receiver-free. + const targets = byMethod.get(expr.method) ?? []; + return targets.length > 0 && targets.every(edge); + } + case "libCall": + return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn); + case "toString": + return !opaqueOperand(expr.operand); + default: { + const operands = dataIntrinsicOperands(expr); + return operands === null || !operands.some(opaqueOperand); + } + } + }; if (!reads) - reads = !everyStmtList(fn.body, { - stmt: (stmt) => RECEIVER_FREE_STMTS.has(stmt.kind), - expr: (expr) => { - if (!RECEIVER_FREE_EXPRS.has(expr.kind)) return false; - switch (expr.kind) { - case "call": - return edge(expr.callee); - case "new": - return edge(`%${expr.className}.constructor`); - case "virtualCall": { - // Any class's override may run: admit only when every - // same-named method is itself receiver-free. - const targets = byMethod.get(expr.method) ?? []; - return targets.length > 0 && targets.every(edge); - } - case "libCall": - return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn); - case "toString": - return !opaqueOperand(expr.operand); - default: { - const operands = dataIntrinsicOperands(expr); - return operands === null || !operands.some(opaqueOperand); - } - } - }, + eachNodeOfKinds(fn, scan, (node, statement) => { + const free = statement + ? RECEIVER_FREE_STMTS.has((node as IrStmt).kind) + : expr(node as IrExpr); + if (!free) reads = true; + return free; }); if (reads) readers.add(fn.name); for (const callee of callees) { diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 85b21af7b..df7d6241a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -2,6 +2,7 @@ import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } import type { IrLibFn } from "./builtin-signatures.js"; import { isDirentShape, + recordHandleCodecClass, recordTextCodecClass, RUNTIME_EMITTER_CLASS, RUNTIME_EVENT_CLASS, @@ -144,6 +145,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "util.debuglog", "util.deprecate", "util.inherits", + "util.inheritsPrototype", "util.styleText", ]); @@ -173,6 +175,7 @@ const UTIL_CALLABLE_LIB_FNS: ReadonlySet = new Set([ "util.debuglog", "util.deprecate", "util.inherits", + "util.inheritsPrototype", ]); /** Entry points that link the regex unit: the regex and glob matchers (fs.glob's walk compiles its patterns with the glob matcher), and readline's terminal unit (scr_readline_tty.c), which measures with the regex unit's stripVTControlCharacters. */ @@ -586,7 +589,7 @@ function mentionsDgramSocket(t: IrType, tables: TypeTables): boolean { const textCodecLeaf: CarrierLeaf = (t, tables) => { if (t.kind !== "record") return false; const shape = tables.records.get(t.shapeId); - return shape !== undefined && recordTextCodecClass(shape) !== null; + return shape !== undefined && recordHandleCodecClass(shape) !== null; }; const textDecoderLeaf: CarrierLeaf = (t, tables) => { @@ -596,7 +599,7 @@ const textDecoderLeaf: CarrierLeaf = (t, tables) => { }; /** Whether converting a value of type `t` to or from dyn converts a - * TextEncoder or TextDecoder record, which boxes it as a handle of + * TextEncoder, TextDecoder or StringDecoder record, which boxes it as a handle of * scr_text_codec_dyn.c (the same carrier walk as dgram sockets). */ function mentionsTextCodec(t: IrType, tables: TypeTables): boolean { tables.textCodecCarriers ??= leafCarriers(tables, textCodecLeaf); @@ -858,7 +861,7 @@ const RUNTIME_FEATURE_RULES = { (node.kind === "dynFrom" && mentionsDgramSocket(node.value.type, tables())) || (node.kind === "dynCheck" && mentionsDgramSocket(node.type, tables())), }), - /** A TextEncoder or TextDecoder record converts to or from an untyped + /** A TextEncoder, TextDecoder or StringDecoder record converts to or from an untyped * value (its handle box, or a boxed function, record or collection that * carries one): scr_text_codec_dyn.c. */ textCodecDyn: rule({ diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts index e009e9a11..74f634d4a 100644 --- a/packages/compiler/src/ir/self-captures.ts +++ b/packages/compiler/src/ir/self-captures.ts @@ -118,7 +118,12 @@ export function resolveSelfCaptures(mod: IrModule): IrModule { } if (resolved.size === 0) return mod; + // Only the resolved bodies and the functions that create their closures + // change. + const touched = new Set(resolved.keys()); + for (const name of resolved.keys()) for (const site of sites.get(name)!) touched.add(site.owner); const rewritten = mod.functions.map((fn) => { + if (!touched.has(fn.name)) return fn; const own = resolved.get(fn.name); const self = own === undefined ? null : fn.captures![own]!; let changed = false; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 3dae577a1..c07607608 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -55,7 +55,6 @@ import { typeEquals, typeKey, maybePromiseUnionArmsOk, - unionContainerArmsOk, VOID, } from "./ir.js"; import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js"; @@ -833,9 +832,6 @@ export function validateModule(mod: IrModule): IrValidationError[] { loc: noLoc, }); } - if (arm.kind === "date" && !unionContainerArmsOk(u.arms)) { - errors.push({ message: `union ${u.id}: date arm ${i} beside non-unit arms`, loc: noLoc }); - } if (arm.kind === "record" && !recordsById.has(arm.shapeId)) { errors.push({ message: `union ${u.id}: arm ${i} references undeclared shape "${arm.shapeId}"`, @@ -6409,15 +6405,10 @@ function validateFunction( } } else { // throw: any exception-representable payload (the throw - // statement's rule). Date cannot retain its object kind in the - // untyped exception cell. + // statement's rule). if (e.arg === null) { err("genResume throw with no payload", e.loc); - } else if ( - e.arg.type.kind === "void" || - e.arg.type.kind === "caught" || - e.arg.type.kind === "date" - ) { + } else if (e.arg.type.kind === "void" || e.arg.type.kind === "caught") { err(`genResume throw of a ${e.arg.type.kind} value`, e.loc); } } @@ -6858,11 +6849,7 @@ function validateFunction( checkExpr(s.value); // dyn throws are allowed: the dyn node rides the REF cell arm by // reference (the JS-lane `throw err` of a dyn argument). - if ( - s.value.type.kind === "void" || - s.value.type.kind === "caught" || - s.value.type.kind === "date" - ) { + if (s.value.type.kind === "void" || s.value.type.kind === "caught") { err(`throw of a ${s.value.type.kind} value`, s.loc); } break; diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 937b4b109..e204b8a6b 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -118,22 +118,11 @@ export class NativeCache { } } - /** Whether a marker or entry exists, without reading or checking it. */ + /** Whether an entry exists, without reading or checking it. */ has(family: string, key: string): boolean { return existsSync(join(this.root, family, key)); } - /** Records an empty marker that has() finds. */ - mark(family: string, key: string): void { - try { - const directory = join(this.root, family); - mkdirSync(directory, { recursive: true, mode: 0o700 }); - writeFileSync(join(directory, key), ""); - } catch { - /* Cache failures never change a build's result. */ - } - } - /** Writes `bytes` under their content digest, and returns the digest. */ writeContent(family: string, bytes: Uint8Array): string { const digest = contentDigest(bytes); @@ -178,7 +167,8 @@ export class NativeCache { "object", "sanitizer", "executable", - "linked", + "kept", + "runtime-pack", "binary", "dsym", "coverage", diff --git a/packages/compiler/src/native/compiler.test.ts b/packages/compiler/src/native/compiler.test.ts index 7a1cee62a..a085356a0 100644 --- a/packages/compiler/src/native/compiler.test.ts +++ b/packages/compiler/src/native/compiler.test.ts @@ -1,5 +1,13 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, expect, test, vi } from "vitest"; @@ -7,7 +15,7 @@ import { NativeCompiler } from "./compiler.js"; import type { NativeToolchain } from "./toolchain.js"; import { runNativeFrontend } from "../frontend/pipeline-native.js"; import { emitNativeObject } from "../backend/native-tools.js"; -import { MACOS_ARM64_TARGET } from "../backend/targets.js"; +import { LINUX_X64_GNU_TARGET, MACOS_ARM64_TARGET } from "../backend/targets.js"; import { VOID, type IrModule } from "../ir/ir.js"; import { contentDigest, NativeCache } from "./cache.js"; import { NativeExecutableCache, openNativeExecutableCache } from "./executable-cache.js"; @@ -283,3 +291,90 @@ test.each([false, true])( expect(readdirSync(directory).sort()).toEqual(["output.o", "output.o.ll"]); }, ); + +test.skipIf(process.platform === "win32").each([false, true])( + "Linux links use lld when the driver finds it, and the default linker when lld fails (%s)", + (lldFails) => { + const directory = mkdtempSync(join("/tmp", "scriptc-native-lld-")); + directories.push(directory); + const bin = join(directory, "bin"); + mkdirSync(bin); + const clang = join(bin, "clang"); + writeFileSync(clang, "#!/bin/sh\n"); + chmodSync(clang, 0o755); + writeFileSync(join(bin, "ld.lld"), "#!/bin/sh\n"); + chmodSync(join(bin, "ld.lld"), 0o755); + const entry = join(directory, "entry.ts"); + const output = join(directory, "program"); + const module: IrModule = { + irVersion: 17, + sourceFile: entry, + entry: "%main", + functions: [ + { + name: "%main", + params: [], + locals: [], + body: [], + returnType: VOID, + loc: { file: entry, start: 0, end: 0 }, + }, + ], + }; + vi.mocked(runNativeFrontend).mockReturnValue({ + preflight: [], + entryText: () => "", + entryExports: () => new Map(), + entryContract: () => { + throw new Error("unexpected contract request"); + }, + sourceTexts: () => new Map(), + npmStatic: [], + npmImportSites: new Map(), + dispose: () => {}, + lower: () => ({ + module, + diagnostics: [], + runtimeFences: [], + stats: { statementsTotal: 0, statementsFailed: 0, functionsSkipped: 0 }, + }), + }); + vi.mocked(emitNativeObject).mockImplementation((options) => { + writeFileSync(options.outputPath, "object"); + return [options.outputPath]; + }); + vi.mocked(openNativeExecutableCache).mockReturnValue(null); + const links: string[][] = []; + vi.mocked(execFileSync).mockImplementation((_command, args) => { + const list = args as string[]; + links.push(list); + if (lldFails && list.includes("-fuse-ld=lld")) throw new Error("lld failed"); + writeFileSync(list[list.indexOf("-o") + 1]!, "executable"); + return ""; + }); + const target = LINUX_X64_GNU_TARGET; + const compiler = new NativeCompiler({ + compilerVersion: "0.0.0", + target, + helper: target.helper, + ts7Executable: "unused", + helperExecutable: "unused", + helperPackageRoot: "unused", + runtimePackRoot: "unused", + linker: clang, + linkerArgs: [], + dsymutil: "unused", + }); + const result = compiler.compile(entry, { + outDir: directory, + outPath: output, + backend: "llvm", + optimization: "release", + strip: true, + }); + expect(result).toMatchObject({ ok: true, binaryPath: output }); + expect(links[0]).toContain("-fuse-ld=lld"); + expect(links.length).toBe(lldFails ? 2 : 1); + if (lldFails) expect(links[1]).not.toContain("-fuse-ld=lld"); + }, +); diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 63769fba6..e919090a9 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -31,7 +31,9 @@ import { } from "../backend/native-tools.js"; import { selectNativeRuntimePack, + runtimePackManifest, stageNativeRuntimeSelection, + type RuntimePackStore, } from "../backend/runtime-pack-native.js"; import { RuntimePackError } from "../backend/runtime-pack-core.js"; import { @@ -65,6 +67,7 @@ import { import { splitLlvmLibraryProgram } from "../backend/llvm/split.js"; import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js"; import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js"; +import { lldLinkerArgs } from "../backend/lld.js"; import { openNativeExecutableCache } from "./executable-cache.js"; import { nativeFileIdentity } from "./file-identity.js"; @@ -160,6 +163,17 @@ export class NativeCompiler { return result; } + /** Runtime pack selections and verified artifact identities, kept in the + * cache. */ + private runtimePackStore(): RuntimePackStore | undefined { + const cache = this.cache; + if (cache === null) return undefined; + return { + read: (key) => cache.read("runtime-pack", key)?.toString("utf8") ?? null, + write: (key, text) => cache.write("runtime-pack", key, text), + }; + } + private emitObject( input: string, output: string, @@ -394,6 +408,8 @@ export class NativeCompiler { toolchain.compilerVersion, features, optimization, + "executable", + this.runtimePackStore(), ) : null; const executableCache = @@ -407,7 +423,9 @@ export class NativeCompiler { ffi, executablePack, ); - const restored = executableCache?.restore(stagedOutput) ?? false; + // An unchanged rebuild keeps the executable the last build left. + const kept = executableCache?.keep(output) ?? false; + const restored = kept || (executableCache?.restore(stagedOutput) ?? false); timing(restored ? "executable-cache-hit" : "executable-cache-miss"); if (!restored) { let programObjects = [object]; @@ -449,16 +467,24 @@ export class NativeCompiler { toolchain.compilerVersion, features, optimization, + "executable", + this.runtimePackStore(), ) : null; - if (pack !== null) stageNativeRuntimeSelection(pack, join(stage, "runtime")); + if (pack !== null) + stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); const info = pack === null ? undefined : formatNativeLinkInfo( { programObject: output, target, ffi }, toolchain.compilerVersion, - pack, + { + root: pack.root, + manifest: runtimePackManifest(pack), + selected: pack.selected, + flavor: pack.flavor, + }, ); renameSync(stagedOutput, output); return { @@ -473,7 +499,7 @@ export class NativeCompiler { const pack = executablePack!; const runtime = options.sanitize ? buildSanitizedRuntime(toolchain, pack, join(stage, "runtime"), "executable", this.cache) - : stageNativeRuntimeSelection(pack, join(stage, "runtime")); + : stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); timing("runtime-stage"); const plan = executableLinkInputs({ target, @@ -492,8 +518,11 @@ export class NativeCompiler { : { windowsSubsystem: options.windowsSubsystem }), ...(profile?.generate === undefined ? {} : { profileGenerate: true }), }); + // Linux links with lld when the driver finds it. + const lld = options.sanitize ? [] : lldLinkerArgs(target.platform, toolchain.linker); const linkArgs = [ ...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs), + ...lld, ...plan.driverFlags, ...plan.inputs, ...plan.systemLibraries.map((name) => `-l${name}`), @@ -504,8 +533,20 @@ export class NativeCompiler { timing("link-inputs"); // A successful dependency trace already produced this executable. // Keep its verified output instead of asking the linker to repeat it. - if (!cacheable) - runNativeTool(options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker, linkArgs); + if (!cacheable) { + const driver = options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker; + try { + runNativeTool(driver, linkArgs); + } catch (error) { + // A failed link with lld runs again with the driver's default + // linker, whose error (if any) is the one reported. + if (lld.length === 0) throw error; + runNativeTool( + driver, + linkArgs.filter((arg) => !lld.includes(arg)), + ); + } + } timing("native-link"); requireNativeArtifact(stagedOutput); if (target.platform === "darwin" && optimization === "dev" && !options.strip) { @@ -524,7 +565,10 @@ export class NativeCompiler { irPath = join(options.outDir, `${stem}.ir.json`); writeTextPartsSync(irPath, prepared.ir!); } - renameSync(stagedOutput, output); + if (!kept) { + renameSync(stagedOutput, output); + executableCache?.recordOutput(output); + } if (target.platform === "darwin" && (optimization !== "dev" || options.strip)) rmSync(output + ".dSYM", { recursive: true, force: true }); return { @@ -617,6 +661,7 @@ export class NativeCompiler { features, profile.optimization, profile.instancePerThread ? "library-thread" : "library", + this.runtimePackStore(), ); const runtime = options.sanitize ? buildSanitizedRuntime( @@ -626,7 +671,7 @@ export class NativeCompiler { profile.instancePerThread ? "library-thread" : "library", this.cache, ) - : stageNativeRuntimeSelection(pack, join(stage, "runtime")); + : stageNativeRuntimeSelection(pack, join(stage, "runtime"), this.runtimePackStore()); timing("runtime-stage"); const stagedArchive = join(stage, wasm ? "output.wasm" : "output.a"); const localizeSymbols = libraryLocalizeSymbols(profile); diff --git a/packages/compiler/src/native/executable-cache-open.test.ts b/packages/compiler/src/native/executable-cache-open.test.ts index 05e57148d..09a8b9cc9 100644 --- a/packages/compiler/src/native/executable-cache-open.test.ts +++ b/packages/compiler/src/native/executable-cache-open.test.ts @@ -52,7 +52,7 @@ function fixture(platform: string, driver: string) { } as unknown as NativeToolchain; const pack = { root: runtime, - manifest: { licenses: [] }, + licenses: [], packageText: "{}", manifestText: "{}", selected: { runtime: [], archives: [] }, diff --git a/packages/compiler/src/native/executable-cache.test.ts b/packages/compiler/src/native/executable-cache.test.ts index 83a493721..97f9a3571 100644 --- a/packages/compiler/src/native/executable-cache.test.ts +++ b/packages/compiler/src/native/executable-cache.test.ts @@ -224,7 +224,7 @@ test("a failed or empty link trace cannot publish a completed executable", () => }); test.each(["ld", "crt", "libc"] as const)( - "a Linux key's second link records the linker, startup objects and libc; replacing the %s misses", + "a Linux key's first link records the linker, startup objects and libc; replacing the %s misses", (input) => { const f = fixture(); const lib = join(f.root, "lib"); @@ -257,26 +257,46 @@ test.each(["ld", "crt", "libc"] as const)( }; }); const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true); - // A key's first link is not traced; the caller links the output itself. - expect(open().trace(["-o", f.output], f.stage)).toBe(false); - expect(spawnSync).not.toHaveBeenCalled(); - expect(open().restore(f.destination)).toBe(false); - // Its next link traces both the driver and the linker in one run. + // A key's first link traces the driver and the linker in one run, and + // records the output it leaves instead of storing the executable. const entry = open(); expect(entry.trace(["-o", f.output], f.stage)).toBe(true); expect(spawnSync).toHaveBeenCalledTimes(1); expect(vi.mocked(spawnSync).mock.calls[0]![1]).toEqual(["-o", f.output, "-v", "-Wl,-t"]); entry.publish(f.output); + expect(f.cache.read("executable", f.key)).toBeNull(); + entry.recordOutput(f.output); + expect(open().keep(f.output)).toBe(true); const recorded = ( - JSON.parse(f.cache.read("executable", f.key)!.toString()) as { inputs: { path: string }[] } + JSON.parse(f.cache.read("kept", f.key)!.toString()) as { inputs: { path: string }[] } ).inputs.map((saved) => saved.path); expect(recorded).toEqual(expect.arrayContaining([...Object.values(paths), nonshared, lib])); expect(recorded).not.toContain(program); - expect(open().restore(f.destination)).toBe(true); const before = statSync(paths[input]); writeFileSync(paths[input] + ".new", "x".repeat(before.size)); utimesSync(paths[input] + ".new", before.atime, before.mtime); renameSync(paths[input] + ".new", paths[input]); - expect(open().restore(f.destination)).toBe(false); + expect(open().keep(f.output)).toBe(false); }, ); + +test("a Linux build whose output changed links again, and then stores the executable", () => { + const f = fixture(); + const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true); + const first = open(); + expect(first.trace(["-o", f.output], f.stage)).toBe(true); + first.publish(f.output); + first.recordOutput(f.output); + // The output is overwritten: it is not kept, and the next link publishes. + writeFileSync(f.output, Buffer.from([1, 2, 3, 4])); + expect(open().keep(f.output)).toBe(false); + const second = open(); + expect(second.restore(f.destination)).toBe(false); + expect(second.trace(["-o", f.output], f.stage)).toBe(true); + second.publish(f.output); + second.recordOutput(f.output); + expect(open().keep(f.output)).toBe(true); + // The stored executable restores at another path. + expect(open().restore(f.destination)).toBe(true); + expect(readFileSync(f.destination)).toEqual(readFileSync(f.output)); +}); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index 1dfbf0b00..0ce638d54 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -124,6 +124,41 @@ function tracePaths(output: string, driver: boolean, roots: string[]): string[] return [...paths].sort(); } +/** Linux: the output a build left, which an unchanged rebuild keeps while + * the output and every input keep their identities. */ +interface KeptEntry { + schema: "scriptc.native-kept.v1"; + output: InputIdentity; + inputs: InputIdentity[]; +} + +function validIdentity(input: InputIdentity): boolean { + return ( + input !== null && + typeof input === "object" && + typeof input.path === "string" && + isAbsolute(input.path) && + typeof input.canonical === "string" && + (input.kind === "file" || input.kind === "directory") && + [input.dev, input.ino, input.size, input.mtime, input.ctime].every( + (value) => typeof value === "number" && Number.isFinite(value), + ) + ); +} + +function validKept(value: unknown): value is KeptEntry { + if (value === null || typeof value !== "object") return false; + const entry = value as Partial; + return ( + entry.schema === "scriptc.native-kept.v1" && + entry.output !== undefined && + validIdentity(entry.output) && + Array.isArray(entry.inputs) && + entry.inputs.length > 0 && + entry.inputs.every(validIdentity) + ); +} + function validEntry(value: unknown): value is ExecutableEntry { if (value === null || typeof value !== "object") return false; const entry = value as Partial; @@ -135,18 +170,7 @@ function validEntry(value: unknown): value is ExecutableEntry { (typeof entry.symbols === "string" && /^[0-9a-f]{64}$/.test(entry.symbols))) && Array.isArray(entry.inputs) && entry.inputs.length > 0 && - entry.inputs.every( - (input) => - input !== null && - typeof input === "object" && - typeof input.path === "string" && - isAbsolute(input.path) && - typeof input.canonical === "string" && - (input.kind === "file" || input.kind === "directory") && - [input.dev, input.ino, input.size, input.mtime, input.ctime].every( - (value) => typeof value === "number" && Number.isFinite(value), - ), - ) + entry.inputs.every(validIdentity) ); } @@ -178,8 +202,8 @@ function stageSymbols(bytes: Buffer, binary: string): void { export class NativeExecutableCache { private inputs: InputIdentity[]; private traced = false; - /** Linux: no entry exists and the key was never linked before. */ - private readonly firstLink: boolean; + /** The inputs of the entry restore() used. */ + private restoredInputs: InputIdentity[] | null = null; constructor( private readonly cache: NativeCache, @@ -187,30 +211,46 @@ export class NativeExecutableCache { private readonly linker: string, private readonly debug: boolean, paths: string[], - /** Linux: trace with one verbose link, from a key's second link on. */ + /** Linux: trace with one verbose link, keep an unchanged output, and + * store the executable from a key's second link on. */ private readonly linux = false, ) { - this.firstLink = linux && !cache.has("executable", key) && !cache.has("linked", key); - // A first link neither restores nor traces, so it needs no identities. - this.inputs = this.firstLink ? [] : [...new Set(paths)].sort().map(identity); + this.inputs = [...new Set(paths)].sort().map(identity); + } + + /** Whether `recorded`, the inputs of an entry, still match and cover this + * build's inputs. */ + private covers(recorded: InputIdentity[]): boolean { + if (!stillMatches(recorded) || !stillMatches(this.inputs)) return false; + const saved = new Map(recorded.map((input) => [input.path, input])); + return this.inputs.every((input) => { + const found = saved.get(input.path); + return found !== undefined && sameIdentity(found, input); + }); + } + + /** Linux: true when `output` is the executable the last build of this key + * left there, unchanged, and its inputs are unchanged; the caller keeps it. */ + keep(output: string): boolean { + if (!this.linux) return false; + try { + const metadata = this.cache.read("kept", this.key); + if (metadata === null) return false; + const entry: unknown = JSON.parse(metadata.toString("utf8")); + if (!validKept(entry)) return false; + const current = identity(output); + return sameIdentity(current, entry.output) && this.covers(entry.inputs); + } catch { + return false; + } } restore(output: string): boolean { - if (this.firstLink) return false; try { const metadata = this.cache.read("executable", this.key); if (metadata === null) return false; const entry: unknown = JSON.parse(metadata.toString("utf8")); - if (!validEntry(entry) || !stillMatches(entry.inputs) || !stillMatches(this.inputs)) - return false; - const saved = new Map(entry.inputs.map((input) => [input.path, input])); - if ( - !this.inputs.every((input) => { - const recorded = saved.get(input.path); - return recorded !== undefined && sameIdentity(recorded, input); - }) - ) - return false; + if (!validEntry(entry) || !this.covers(entry.inputs)) return false; if (this.debug !== (entry.symbols !== null)) return false; const binary = this.cache.read("binary", entry.binary); const symbols = entry.symbols === null ? null : this.cache.read("dsym", entry.symbols); @@ -219,6 +259,7 @@ export class NativeExecutableCache { if (symbols !== null) stageSymbols(symbols, output); writeFileSync(output, binary); chmodSync(output, 0o755); + this.restoredInputs = entry.inputs; return true; } catch { return false; @@ -234,12 +275,6 @@ export class NativeExecutableCache { trace(args: string[], stage: string): boolean { this.traced = false; try { - // On Linux a key's first link only records that it was linked; a later - // link of the same key traces, so a build that runs once traces nothing. - if (this.firstLink) { - this.cache.mark("linked", this.key); - return false; - } const roots = [resolve(stage), realpathSync(stage)]; let driverInputs = this.linux ? [] @@ -250,7 +285,14 @@ export class NativeExecutableCache { const paths = tracePaths(linked, false, roots); if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs)) return false; - this.inputs.push(...driverInputs, ...paths.map(identity)); + // The driver's job line and the linker's trace name many of the same + // files; each is checked once. + const listed = new Set(this.inputs.map((input) => input.path)); + for (const input of [...driverInputs, ...paths.map(identity)]) + if (!listed.has(input.path)) { + listed.add(input.path); + this.inputs.push(input); + } this.traced = true; return true; } catch { @@ -260,7 +302,10 @@ export class NativeExecutableCache { publish(output: string): void { try { - if (!this.traced || !stillMatches(this.inputs)) return; + // On Linux the first build of a key only records its output (see + // recordOutput); the executable is stored once a later build relinks. + if (!this.traced || (this.linux && !this.cache.has("kept", this.key))) return; + if (!stillMatches(this.inputs)) return; const binary = readFileSync(output); const symbols = this.debug ? readSymbols(output) : null; const binaryKey = this.cache.writeContent("binary", binary); @@ -277,6 +322,24 @@ export class NativeExecutableCache { /* Build artifacts remain valid when caching is unavailable. */ } } + + /** Linux: records `output`, the executable this build left in place, for + * keep(). */ + recordOutput(output: string): void { + if (!this.linux) return; + try { + const inputs = this.traced ? this.inputs : this.restoredInputs; + if (inputs === null || !stillMatches(inputs)) return; + const entry: KeptEntry = { + schema: "scriptc.native-kept.v1", + output: identity(output), + inputs, + }; + this.cache.write("kept", this.key, JSON.stringify(entry)); + } catch { + /* Build artifacts remain valid when caching is unavailable. */ + } + } } export function openNativeExecutableCache( @@ -331,8 +394,8 @@ export function openNativeExecutableCache( join(toolchain.helperPackageRoot, "package.json"), join(toolchain.runtimePackRoot, "package.json"), join(toolchain.runtimePackRoot, "runtime-pack.json"), - ...[...pack.selected.runtime, ...pack.selected.archives, ...pack.manifest.licenses].map( - (artifact) => join(pack.root, artifact.path), + ...[...pack.selected.runtime, ...pack.selected.archives, ...pack.licenses].map((artifact) => + join(pack.root, artifact.path), ), ]; if (platform === "linux") paths.push(join(pack.root, "runtime-pack.json")); diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 7ae2d1d5f..5d0f9faae 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -70,7 +70,7 @@ function fixture() { loads++; return runFrontend(path, loadProgram, npmStatic, externalTypes); }; - const prepare = (extra: { emitIr?: boolean } = {}) => + const prepare = (extra: { emitIr?: boolean; optimization?: "release" | "dev" } = {}) => prepareNativeExecutable(entry, { ...options, ...extra }, null, toolchain, frontend, cache); return { root, entry, dependency, cache, options, prepare, loads: () => loads }; } @@ -123,6 +123,38 @@ test("comment and whitespace edits reuse the frontend output a fresh build repro expect(edited.ok && edited.llvm.join("")).not.toBe(first.llvm.join("")); }); +test("a dev build reuses its frontend output after edits that keep every token's position", () => { + const f = fixture(); + const dev = { optimization: "dev" as const }; + const first = f.prepare(dev); + expect(first.ok, JSON.stringify(first)).toBe(true); + if (!first.ok) return; + // Debug information records lines and columns: a comment after the last + // token and trailing whitespace keep them. + const appended = 'export const value = "first"; \n// The exported value.\n'; + writeFileSync(f.dependency, appended); + const reused = f.prepare(dev); + expect(f.loads()).toBe(1); + expect(reused.ok).toBe(true); + if (!reused.ok) return; + expect(reused.llvm.join("")).toBe(first.llvm.join("")); + expect(new Map(reused.sources).get(f.dependency)).toBe(appended); + const fresh = prepareNativeExecutable( + f.entry, + { ...f.options, ...dev }, + null, + fixtureToolchain(), + (path, npmStatic, externalTypes) => runFrontend(path, loadProgram, npmStatic, externalTypes), + new NativeCache(join(f.root, "fresh-cache")), + ); + expect(fresh.ok && fresh.llvm.join("")).toBe(first.llvm.join("")); + // A comment that moves the token after it is rebuilt. + writeFileSync(f.dependency, '// The exported value.\nexport const value = "first";\n'); + const moved = f.prepare(dev); + expect(f.loads()).toBe(2); + expect(moved.ok && moved.llvm.join("")).not.toBe(first.llvm.join("")); +}); + test("a comment edit inside source text the output carries is rebuilt", () => { const f = fixture(); // A failing assert(value) reports its call's source text. diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 67a4b5a9e..c15bfbf9b 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -2,6 +2,7 @@ import { targetCpuField, targetCpuTargetRefusal } from "../backend/target-cpu.js import { nativeCodegenDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js"; import type { CompilationTiming } from "../timing.js"; import { readFileSync } from "node:fs"; +import { availableParallelism } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import type { CompileFailure, @@ -31,7 +32,10 @@ import { import { compilerOwnedFileRoots } from "../frontend/dts-paths.js"; import { provenanceSources } from "../frontend/provenance-registry.js"; import { processFork } from "../frontend/process-fork.js"; -import { tokenEquivalentSource } from "../frontend/token-equivalence.js"; +import { + tokenEquivalentSource, + tokenPositionEquivalentSource, +} from "../frontend/token-equivalence.js"; import { llvmBytes, wtf8Bytes } from "../backend/literals.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; @@ -142,9 +146,11 @@ function readFrontendCache( /** The stale entry's input when every changed source is token-equivalent to * the text the entry was built from (edits to whitespace and to comments * that cannot affect compilation; see token-equivalence.ts). Equivalent - * sources lower to the same IR apart from source locations, which only dev - * builds' debug information and the serialized IR carry. Diagnostics carry - * locations too, so an entry that reported warnings is rebuilt. A changed + * sources lower to the same IR apart from source locations, which only the + * debug information of dev builds and the serialized IR carry. A build + * with debug information accepts only edits that keep every token's line + * and column, the positions the debug information records. Diagnostics + * carry locations too, so an entry that reported warnings is rebuilt. A changed * source whose raw text or line positions the frontend read (call-site text * in messages, comptime callbacks, test locations) is rebuilt as well: * frontendInputsSemanticallyMatch refuses its `text` probe. */ @@ -154,17 +160,17 @@ function equivalentFrontendInput( exclusions: FrontendInputExclusions, ): { input: NativeExecutableInput; probes: FrontendInputSnapshot } | null { if ( - options.optimization === "dev" || options.sanitize === true || options.emitIr === true || stale.ir !== null || (stale.input.warnings?.length ?? 0) > 0 ) return null; + const debugInfo = options.optimization === "dev" && !options.strip; const match = frontendInputsSemanticallyMatch( stale.probes, new Map(stale.input.sources), - tokenEquivalentSource, + debugInfo ? tokenPositionEquivalentSource : tokenEquivalentSource, exclusions, ); if (match === null || match.changed.length === 0) return null; @@ -402,6 +408,7 @@ export function prepareNativeExecutable( pointerBits: toolchain.target.pointerBits, wasi: toolchain.target.platform === "wasi", runtimeAbiMarker: outputKind === "obj" || outputKind === "exe", + unwindAbi: outputKind === "exe" && toolchain.target.platform === "linux", objectAudit: options.sanitize === true, lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true), functionWorkers: emitWorkers(), @@ -546,10 +553,12 @@ export function prepareNativeLibrary( return input; } -/** Worker processes that emit function bodies at a time - * (SCRIPTC_EMIT_WORKERS; 0 or 1 emits in this process). */ +/** Worker processes that emit function bodies at a time: up to eight, one + * per available core (SCRIPTC_EMIT_WORKERS sets the count; 0 or 1 emits in + * this process). */ function emitWorkers(): number { - const value = Number(process.env["SCRIPTC_EMIT_WORKERS"] ?? "4"); + const configured = process.env["SCRIPTC_EMIT_WORKERS"]; + const value = configured === undefined ? Math.min(8, availableParallelism()) : Number(configured); return Number.isFinite(value) && value > 1 ? Math.floor(value) : 1; } diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 1415f2971..31a8c3355 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -64,6 +64,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( { root: directory, manifest, + licenses: manifest.licenses, packageText: "", manifestText: "", flavor: "dev", diff --git a/packages/compiler/src/native/sanitizer.ts b/packages/compiler/src/native/sanitizer.ts index cb3728ec0..5d228a615 100644 --- a/packages/compiler/src/native/sanitizer.ts +++ b/packages/compiler/src/native/sanitizer.ts @@ -4,7 +4,11 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { NativeCodegenError } from "../backend/native-codegen-core.js"; import { requireNativeArtifact, runNativeTool } from "../backend/native-tools.js"; -import type { NativeRuntimePack, NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; +import { + runtimePackManifest, + type NativeRuntimePack, + type NativeRuntimeSelection, +} from "../backend/runtime-pack-native.js"; import { runtimePackFlavorKey, type RuntimePackMode } from "../backend/runtime-pack-core.js"; import { LRE_SOURCES, ZLIB_SOURCES } from "../backend/vendor-inputs.js"; import { contentDigest, type NativeCache } from "./cache.js"; @@ -119,8 +123,9 @@ export function buildSanitizedRuntime( } return output; }; - const key = runtimePackFlavorKey(selection.manifest, selection.flavor, mode); - const flavor = selection.manifest.flavors[key]; + const manifest = runtimePackManifest(selection); + const key = runtimePackFlavorKey(manifest, selection.flavor, mode); + const flavor = manifest.flavors[key]; if (flavor === undefined) throw new NativeCodegenError("SC3003", `runtime pack lacks ${key}`); const wanted = new Set(selection.selected.runtime.map((artifact) => artifact.path)); const common = [ diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 1afd4cd3a..58b3da7e2 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -4229,6 +4229,13 @@ "status": "static", "note": "one date string, using V8's date parser shared with new Date(dateString)" }, + { + "id": "stdlib.date.setters", + "kind": "stdlib", + "name": "Date.prototype setters", + "status": "static", + "note": "setTime and the local and UTC field setters, with their optional arguments" + }, { "id": "stdlib.date.toDateString", "kind": "stdlib", diff --git a/packages/compiler/test/constant-graphs.test.ts b/packages/compiler/test/constant-graphs.test.ts new file mode 100644 index 000000000..a7cbe6118 --- /dev/null +++ b/packages/compiler/test/constant-graphs.test.ts @@ -0,0 +1,164 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { compile } from "../src/index.js"; + +// A library in the pre-module idiom: guarded namespace declarations, a +// revealing module, an ES5 constructor with prototype methods, and the +// cases that keep an object from being constant. +const packageSource = ` +var lib = lib || {}; +lib.math = lib.math || {}; +lib.math.TAU = 6.283185307179586; +lib.math.half = function (x) { return x / 2; }; +lib.text = (function () { + function upper(s) { return s.toUpperCase(); } + return { upper: upper }; +})(); +lib.Point = function (x, y) { this.x = x; this.y = y; }; +lib.Point.prototype.norm = function () { return Math.sqrt(this.x * this.x + this.y * this.y); }; +lib.counter = { hits: 0 }; +lib.bump = function () { lib.counter.hits++; }; +lib.cache = { table: null }; +lib.lookup = function () { return lib.cache.table || (lib.cache.table = [1, 2, 3]); }; +lib.scratch = { a: 1, b: 2 }; +delete lib.scratch.b; +lib.shared = { value: 1 }; +globalThis.sharedFromLib = lib.shared; +function option(name) { return { name: name }; } +lib.options = { first: option("a"), second: option("b") }; +module.exports = { lib }; +`; + +const mainSource = ` +const { lib } = require("ns-fixture"); +let total = 0; +for (let i = 0; i < 3; i++) { + total += lib.math.half(i) + new lib.Point(i, i).norm() + lib.lookup().length; + lib.bump(); +} +console.log(total, lib.text.upper("x"), lib.counter.hits); +`; + +let dir: string; +let saved: string | undefined; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "scriptc-constant-graphs-")); + const pkg = join(dir, "node_modules", "ns-fixture"); + await mkdir(pkg, { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ name: "ns-fixture", version: "1.0.0", main: "index.js", types: "index.d.ts" }), + ); + await writeFile(join(pkg, "index.js"), packageSource); + await writeFile(join(pkg, "index.d.ts"), "declare const lib: any;\nexport { lib };\n"); + await writeFile( + join(dir, "package.json"), + JSON.stringify({ dependencies: { "ns-fixture": "1.0.0" } }), + ); + await writeFile(join(dir, "main.cjs"), mainSource); + saved = process.env["SCRIPTC_DEBUG_SHAPES"]; +}); +afterEach(async () => { + if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPES"]; + else process.env["SCRIPTC_DEBUG_SHAPES"] = saved; + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); +}); + +async function report(): Promise { + process.env["SCRIPTC_DEBUG_SHAPES"] = "const"; + const lines: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + for (const l of text.split("\n")) if (l.startsWith("[shapes] ")) lines.push(l.slice(9)); + return true; + }); + await compile(join(dir, "main.cjs"), { + outDir: dir, + outPath: join(dir, "main.ir.json"), + outputKind: "ir", + npmStatic: ["ns-fixture"], + }); + return lines; +} + +test("constant graphs: namespaces, revealing modules and prototypes, with near misses", async () => { + const lines = await report(); + const text = lines.join("\n"); + // The namespace graph: guarded declarations, the revealing module's + // table, and the constructor function. + expect(text).toMatch( + /graph ns-fixture\/index\.js:\d+ \{lib: \{math: \{TAU: number, half: fn\}, text: \{upper: fn\}/, + ); + // The constructor's prototype object, built by member stores at init. + expect(text).toMatch(/graph ns-fixture\/index\.js:\d+ \{constructor: \{\}, norm: fn\}/); + // A field written by a function called from a loop. + expect(text).toMatch(/near miss ns-fixture\/index\.js:12 \.hits: written-after-init/); + // A lazily filled cache. + expect(text).toMatch(/near miss ns-fixture\/index\.js:14 \.table: lazy-cache/); + // A deleted key, and an object stored into the global object. + expect(text).toMatch(/near miss ns-fixture\/index\.js:16: reflective-write \(delete/); + expect(text).toMatch(/near miss ns-fixture\/index\.js:18: escapes/); + // A factory called from two init sites: one object per call site. + expect(text).toMatch(/near miss ns-fixture\/index\.js:20: init-factory \(.*2 call sites\)/); +}); + +// Functions that one call site runs repeatedly: an ES5 constructor and an +// apply call in a loop, and builtin callbacks that run once per element, +// match or parsed value. +const repeatedSource = ` +var lib = {}; +lib.newState = { v: 0 }; +function bumpNew() { lib.newState.v++; } +bumpNew(); +lib.applyState = { v: 0 }; +function bumpApply() { lib.applyState.v++; } +bumpApply(); +lib.repeat = function () { for (var i = 0; i < 3; i++) { new bumpNew(); bumpApply.apply(null, []); } }; +lib.seen = { match: null, pair: null, mapped: null, key: null }; +lib.replaced = "a-b".replace(/\\\\w/g, function (m) { lib.seen.match = { m: m }; return m; }); +lib.sorted = [3, 1, 2].toSorted(function (a, b) { lib.seen.pair = { a: a }; return a - b; }); +lib.mapped = Array.from([1, 2], function (x) { lib.seen.mapped = { x: x }; return x; }); +lib.revived = JSON.parse('{"a":{"b":1}}', function (k, v) { lib.seen.key = { k: k }; return v; }); +module.exports = { lib }; +`; + +test("constant graphs: constructors, apply calls and callbacks that one site runs repeatedly", async () => { + await writeFile(join(dir, "node_modules", "ns-fixture", "index.js"), repeatedSource); + await writeFile( + join(dir, "main.cjs"), + 'const { lib } = require("ns-fixture");\nlib.repeat();\nconsole.log(lib.newState.v, lib.applyState.v, lib.replaced, lib.sorted, lib.mapped, lib.revived);\n', + ); + const text = (await report()).join("\n"); + // `new bumpNew()` and `bumpApply.apply(...)` in a loop write after init. + expect(text).toMatch(/near miss ns-fixture\/index\.js:3 \.v: written-after-init/); + expect(text).toMatch(/near miss ns-fixture\/index\.js:6 \.v: written-after-init/); + // Each callback runs once per match, comparison, element or parsed value. + for (const field of ["match", "pair", "mapped", "key"]) + expect(text).toMatch( + new RegExp(`near miss ns-fixture/index\\.js:10 \\.${field}: written-after-init`), + ); +}); + +// A prototype method stored under a computed key and called on instances. +const computedMethodSource = ` +var lib = {}; +lib.touched = { count: 0 }; +lib.Widget = function () {}; +var methodName = ["to", "uch"].join(""); +lib.Widget.prototype[methodName] = function () { lib.touched.count++; }; +lib.useWidget = function () { new lib.Widget().touch(); }; +module.exports = { lib }; +`; + +test("constant graphs: prototype methods stored under computed keys run on their instances", async () => { + await writeFile(join(dir, "node_modules", "ns-fixture", "index.js"), computedMethodSource); + await writeFile( + join(dir, "main.cjs"), + 'const { lib } = require("ns-fixture");\nfor (let i = 0; i < 3; i++) lib.useWidget();\nconsole.log(lib.touched.count);\n', + ); + const text = (await report()).join("\n"); + expect(text).toMatch(/near miss ns-fixture\/index\.js:3 \.count: written-after-init/); +}); diff --git a/packages/compiler/test/integer-view-compares.test.ts b/packages/compiler/test/integer-view-compares.test.ts index e988d48bd..4e814368d 100644 --- a/packages/compiler/test/integer-view-compares.test.ts +++ b/packages/compiler/test/integer-view-compares.test.ts @@ -22,7 +22,7 @@ siftDown(a, 0, Number(process.argv[2] ?? "5")); console.log(Array.from(a).join()); `; -test("integer sums of speculated locals compare and index as integers", async () => { +test("integer values compare and index as integers", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-views-")); try { const entry = join(dir, "sift.ts"); @@ -43,9 +43,17 @@ test("integer sums of speculated locals compare and index as integers", async () expect(uses("icmp slt").length).toBeGreaterThan(0); // and `a[child + 1]` bounds the integer index without a double round trip. expect(uses("icmp ult").length).toBeGreaterThan(0); - // `2 * root + 1 < end` may pass 2^53, so it keeps the double compare. - expect(body).toMatch(/fmul double 0x4000000000000000, %t\d+\n\s+%t\d+ = mul nsw i64 2/); - expect(body).toContain("fcmp olt double"); + // `2 * root + 1` may pass 2^53, but its double is its integer rounded + // once (doubling is exact) and `end` is a safe integer, so the integers + // compare as the doubles do. + const doubled = /(%t\d+) = mul nsw i64 2, %t\d+\n/.exec(body)![1]!; + const plusOne = new RegExp(`(%t\\d+) = add nsw i64 \\${doubled}, 1\\n`).exec(body)![1]!; + expect(body).toMatch(new RegExp(`= icmp slt i64 \\${plusOne}, %t\\d+\\n`)); + // `a[child]! < a[child + 1]!` compares the elements' int32 bits, and is + // false when either read is out of range (NaN). + const ordered = String.raw`\s+%t\d+ = fcmp ord double %t\d+, 0\.0\n\s+%t\d+ = and i1 %t\d+, %t\d+\n`; + expect(body).toMatch(new RegExp(String.raw`icmp slt i32 %t\d+, %t\d+\n(${ordered}){2}`)); + expect(body).toMatch(new RegExp(String.raw`icmp sge i32 %t\d+, %t\d+\n(${ordered}){2}`)); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index 7f5e9f3c9..5d835ef8a 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -8,12 +8,10 @@ test("hand-built fib module validates", () => { expect(validateModule(fibModule)).toEqual([]); }); -test("validator rejects Date union arms beside other data arms", () => { - const bad = structuredClone(fibModule); - bad.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "string" }] }]; - expect(validateModule(bad).map((e) => e.message)).toContain( - "union u-date: date arm 0 beside non-unit arms", - ); +test("validator accepts Date union arms beside other arms", () => { + const mixed = structuredClone(fibModule); + mixed.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "string" }] }]; + expect(validateModule(mixed)).toEqual([]); const nullable = structuredClone(fibModule); nullable.unions = [{ id: "u-date", arms: [{ kind: "date" }, { kind: "undefinedT" }] }]; expect(validateModule(nullable)).toEqual([]); diff --git a/packages/compiler/test/js-ic-emission.test.ts b/packages/compiler/test/js-ic-emission.test.ts index 47d641bff..54ea13d26 100644 --- a/packages/compiler/test/js-ic-emission.test.ts +++ b/packages/compiler/test/js-ic-emission.test.ts @@ -64,6 +64,50 @@ console.log(read(o), at(dyn([5, 6]), 1), call(o), o.n); } }); +test("property sites keep their caches in programs that declare classes and subclasses", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-class-")); + try { + const entry = join(dir, "main.js"); + const outPath = join(dir, "main.ir.json"); + // Declared classes give every property helper class branches and + // property-bag lookups, and a subclass adds a normalization step; plain + // objects still read through the caches. + await writeFile( + entry, + ` +class Point { + constructor(x) { this.x = x; } +} +class Tagged extends Point { + constructor(x, tag) { super(x); this.tag = tag; } +} +function dyn(v) { return v; } +function read(o) { return o.x; } +function write(o, v) { o.x = v; } +function at(a, i) { return a[i]; } +var o = dyn({ x: 1 }); +var p = dyn(new Point(4)); +var t = dyn(new Tagged(6, "t")); +write(o, 3); +write(p, 5); +write(t, 7); +console.log(read(o), read(p), read(t), at(dyn([5, 6]), 1), at(p, "x"), at(t, "tag")); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(mod)).toEqual([]); + const llvm = emitLlvmModule(mod); + expect(body(llvm, "read")).toContain("@scr_js_load_probe("); + expect(body(llvm, "write")).toContain("@scr_js_store_probe("); + expect(body(llvm, "at")).toContain("@scr_js_keyed_probe("); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("typed programs have no cache sites and keep the ordinary runtime", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-typed-")); try { diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index ce1f14335..fcd0132e8 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -29,8 +29,7 @@ afterEach(async () => { /** A program whose internal functions, constants and state must stay * reachable across partitions. `edit` changes one function's addend. */ -function program(edit = 0, extra = ""): string { - const count = 24; +function program(edit = 0, extra = "", count = 24): string { const lines = [ "; partitioned program", "%Pair = type { i64, i64 }", @@ -74,11 +73,12 @@ function program(edit = 0, extra = ""): string { return lines.join("\n") + "\n"; } -function expectedExit(edit = 0): number { - const addends = Array.from({ length: 24 }, (_, index) => (index === 5 ? index + edit : index)); +function expectedExit(edit = 0, count = 24): number { + const addends = Array.from({ length: count }, (_, index) => (index === 5 ? index + edit : index)); let total = 5; // strlen("total") - for (let start = 0; start < 24; start++) - for (let index = start; index < 24; index++) total += (start === 0 ? 4 : 1) * addends[index]!; + for (let start = 0; start < count; start++) + for (let index = start; index < count; index++) + total += (start === 0 ? 4 : 1) * addends[index]!; return total % 251; } @@ -162,6 +162,56 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { }); } + test("functions under one group line share a partition", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-groups-")); + dirs.push(dir); + // Groups of four steps, each smaller than the mean partition; main ends + // the last group. Placement by group applies from 32 partitions. + const steps = 128; + const grouped = (edit: number, count = steps) => + program(edit, "", count) + .split("\n") + .flatMap((line) => { + const step = /^define internal void @step(\d+)\(/.exec(line); + if (step !== null && Number(step[1]) % 4 === 0) + return [`; scriptc-group ${Number(step[1]) / 4}`, line]; + return line.startsWith("define i32 @main(") ? ["; scriptc-group", line] : [line]; + }) + .join("\n"); + const cache = join(dir, "cache"); + const options = { outputs: 32, optLevel: "0", cache } as const; + const objects = await emit(dir, grouped(0), { ...options, name: "grouped" }); + expect(await exitCode(dir, objects, "grouped")).toBe(expectedExit(0, steps)); + const owner = new Map(); + for (const [index, object] of objects.entries()) { + const { stdout } = await execFileAsync("nm", [object]); + for (const line of stdout.split("\n")) { + const defined = /^[0-9a-fA-F]+ [A-Za-z] _?step(\d+)$/.exec(line.trim()); + if (defined !== null) owner.set(Number(defined[1]), index); + } + } + expect(owner.size).toBe(steps); + // step0's only caller is main, so it goes with main. + for (let group = 0; group < steps / 4; group++) { + const members = [0, 1, 2, 3].map((offset) => group * 4 + offset).filter((step) => step > 0); + expect(new Set(members.map((step) => owner.get(step))).size).toBe(1); + } + + // An edit to one function still changes one partition's source. + const sources = await cacheEntries(cache, ".o"); + const edited = await emit(dir, grouped(7), { ...options, name: "edited" }); + const after = await cacheEntries(cache, ".o"); + expect([...after].filter((entry) => !sources.has(entry))).toHaveLength(1); + expect(await exitCode(dir, edited, "edited")).toBe(expectedExit(7, steps)); + + // With fewer partitions, functions keep their placement by name. + const contents = (paths: string[]) => Promise.all(paths.map((path) => readFile(path))); + const few = { outputs: 4, optLevel: "0" } as const; + expect( + await contents(await emit(dir, grouped(0, 24), { ...few, name: "few-grouped" })), + ).toEqual(await contents(await emit(dir, program(), { ...few, name: "few-plain" }))); + }); + test("a table written one element per line keeps the text partitioner and its reuse", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-table-")); dirs.push(dir); diff --git a/packages/compiler/test/shape-inference.test.ts b/packages/compiler/test/shape-inference.test.ts new file mode 100644 index 000000000..911cef79a --- /dev/null +++ b/packages/compiler/test/shape-inference.test.ts @@ -0,0 +1,302 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { compile } from "../src/index.js"; + +// A package shaped like a validation library: schema classes with a virtual +// `_parse`, per-field context and result literals, a class holding the +// context, and an output object built with computed keys. +const packageSource = ` +export class Status { + constructor() { this.value = "valid"; } + dirty() { if (this.value === "valid") this.value = "dirty"; } +} +class LazyPath { + constructor(parent, data, key) { this.parent = parent; this.data = data; this.key = key; } + get path() { return [this.key]; } +} +export class Base { + constructor(def) { this._def = def; } + ctxOf(input) { return { common: input.parent.common, data: input.data, parent: input.parent }; } + parse(data) { + const root = { common: { issues: [] }, data, parent: null }; + const result = this._parse({ data, parent: root }); + return result.value; + } +} +export class Str extends Base { + _parse(input) { + const status = new Status(); + if (typeof input.data !== "string") status.dirty(); + return { status: status.value, value: input.data }; + } +} +export class Obj extends Base { + _parse(input) { + const ctx = this.ctxOf(input); + const pairs = []; + for (const key of Object.keys(this._def.shape)) { + const field = this._def.shape[key]; + pairs.push({ key, value: field._parse(new LazyPath(ctx, ctx.data[key], key)) }); + } + const out = {}; + for (const pair of pairs) out[pair.key] = pair.value.value; + return { status: "valid", value: out }; + } +} +export const str = () => new Str({ checks: [] }); +export const obj = (shape) => new Obj({ shape }); +export function leak(value) { globalThis.leaked = value; return value; } +export function reflect() { const o = { a: 1 }; delete o.a; return o; } +class Box {} +function tag(strings, ...values) { leak(values[0]); return strings.raw.length; } +export function arrayProp() { const list = []; list.meta = { listed: 1 }; return leak(list.meta); } +export function computedField(k) { + const box = new Box(); + box[k] = { boxed: 1 }; + Box[k] = { onClass: 1 }; + const fn = function () {}; + fn[k] = { onFunction: 1 }; + return [leak(box.held), leak(Box.held), leak(fn.held)]; +} +export function revive(text) { + const seen = { revived: 1 }; + return JSON.parse(text, function (k, v) { globalThis.revived = seen; return v; }); +} +class Doc { + constructor() { this.body = { doc: 1 }; } + toJSON() { return leak(this.body); } +} +export function stringified() { + const own = { owned: 1 }; + return [ + JSON.stringify({ inner: { replaced: 1 } }, (k, x) => leak(x)), + JSON.stringify(new Doc()), + JSON.stringify({ toJSON() { return leak(own); } }), + ]; +} +export function proxied() { return new Proxy({ proxied: 1 }, {}); } +export function reflectDelete() { const o = { gone: 1 }; Reflect.deleteProperty(o, "gone"); return o; } +export function tagged() { return tag\`a\${{ part: 1 }}b\`; } +export function arrayComputed(k) { const l = []; l[k] = { arrayComputed: 1 }; return leak(l.meta); } +export function shadowed(k) { + const fn = function () {}; + fn[k] = { onPrototype: 1 }; + const box = new Box(); + box[k] = { onConstructor: 1 }; + return [leak(fn.prototype), leak(box.constructor)]; +} +export function jsonCallbacks(k) { + const inArray = { inArray: 1 }; + const list = []; + list.toJSON = function () { globalThis.inArray = inArray; return 1; }; + const inFunction = { inFunction: 1 }; + const fn = function () {}; + fn.toJSON = function () { globalThis.inFunction = inFunction; return 1; }; + const underKey = { underKey: 1 }; + const o = {}; + o[k] = function () { globalThis.underKey = underKey; return 1; }; + return JSON.stringify([list, { fn }, o]); +} +export function revivedResult(text) { const r = { fromReviver: 1 }; return leak(JSON.parse(text, function () { return r; })); } +export function constructedProxy() { return Reflect.construct(Proxy, [{ constructedProxy: 1 }, {}]); } +export function globalProxy() { return new globalThis.Proxy({ globalProxy: 1 }, {}); } +`; + +const packageTypes = ` +export declare class Base { parse(data: unknown): unknown; } +export declare class Str extends Base {} +export declare class Obj extends Base {} +export declare const str: () => Str; +export declare const obj: (shape: Record) => Obj; +export declare function leak(value: T): T; +export declare function reflect(): object; +export declare function arrayProp(): unknown; +export declare function computedField(k: string): unknown; +export declare function revive(text: string): unknown; +export declare function stringified(): unknown; +export declare function proxied(): unknown; +export declare function reflectDelete(): unknown; +export declare function tagged(): unknown; +export declare function arrayComputed(k: string): unknown; +export declare function shadowed(k: string): unknown; +export declare function jsonCallbacks(k: string): unknown; +export declare function revivedResult(text: string): unknown; +export declare function constructedProxy(): unknown; +export declare function globalProxy(): unknown; +`; + +const mainSource = ` +import { + arrayComputed, + arrayProp, + computedField, + constructedProxy, + globalProxy, + jsonCallbacks, + leak, + obj, + proxied, + reflect, + reflectDelete, + revive, + revivedResult, + shadowed, + str, + stringified, + tagged, +} from "shapes-fixture"; +const schema = obj({ a: str(), b: str() }); +const input: unknown = JSON.parse('{"a":"x","b":"y"}'); +console.log(JSON.stringify(schema.parse(input))); +console.log(JSON.stringify(leak({ kept: 1 })), JSON.stringify(reflect())); +const more = [ + arrayProp(), + computedField("held"), + revive("{}"), + stringified(), + proxied(), + reflectDelete(), + tagged(), + arrayComputed("meta"), + shadowed("prototype"), + jsonCallbacks("toJSON"), + revivedResult("{}"), + constructedProxy(), + globalProxy(), +]; +console.log(more.length); +`; + +let dir: string; +let saved: string | undefined; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "scriptc-shapes-")); + const pkg = join(dir, "node_modules", "shapes-fixture"); + await mkdir(pkg, { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ + name: "shapes-fixture", + version: "1.0.0", + type: "module", + main: "index.js", + types: "index.d.ts", + }), + ); + await writeFile(join(pkg, "index.js"), packageSource); + await writeFile(join(pkg, "index.d.ts"), packageTypes); + await writeFile( + join(dir, "package.json"), + JSON.stringify({ type: "module", dependencies: { "shapes-fixture": "1.0.0" } }), + ); + await writeFile(join(dir, "main.ts"), mainSource); + saved = process.env["SCRIPTC_DEBUG_SHAPES"]; +}); +afterEach(async () => { + if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPES"]; + else process.env["SCRIPTC_DEBUG_SHAPES"] = saved; + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); +}); + +async function report(): Promise { + process.env["SCRIPTC_DEBUG_SHAPES"] = "verbose"; + const lines: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + for (const l of text.split("\n")) if (l.startsWith("[shapes] ")) lines.push(l.slice(9)); + return true; + }); + const entry = join(dir, "main.ts"); + await compile(entry, { + outDir: dir, + outPath: join(dir, "main.ir.json"), + outputKind: "ir", + npmStatic: ["shapes-fixture"], + }); + return lines; +} + +function site(lines: string[], label: RegExp): string { + const line = lines.find((l) => l.startsWith("site ") && label.test(l)); + if (line === undefined) throw new Error(`no site ${label}:\n${lines.join("\n")}`); + return line; +} + +test("S1 report: per-field temporaries are provable records, dynamic objects stay G", async () => { + const lines = await report(); + // result literals of both _parse implementations, the pair literal, the + // context and the root context never escape: native records would be exact + // the leaf schema's result: in a function called from a loop (hot) + expect(site(lines, /^site \{status,value\}@shapes-fixture\/index\.js:23 \[hot\]/)).toMatch( + /: P-local/, + ); + expect(site(lines, /^site \{status,value\}@shapes-fixture\/index\.js:36/)).toMatch(/: P-local/); + expect(site(lines, /^site \{key,value\}@/)).toMatch(/: P-local/); + expect(site(lines, /^site \{common,data,parent\}@shapes-fixture\/index\.js:12/)).toMatch( + /: P-local/, + ); + expect(site(lines, /^site \[\]@shapes-fixture\/index\.js:29/)).toMatch(/: P-local/); + // the output object is built with computed keys + expect(site(lines, /^site \{\}@shapes-fixture\/index\.js:34/)).toMatch(/: G — computed write/); + // a deleted key and a value stored into the global object + expect(site(lines, /^site \{a\}@/)).toMatch(/: G — delete at/); + expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/); + // receiver-typed dispatch: the field's validators are the schema classes + expect(lines.find((l) => l.startsWith("fn Str._parse@"))).toMatch(/p0=LazyPath/); + expect(lines.find((l) => l.startsWith("class LazyPath"))).toMatch( + /parent: \{common,data,parent\}@shapes-fixture\/index\.js:12;/, + ); + expect(lines.find((l) => l.startsWith("class Str"))).toMatch(/_def: \{checks\}@/); +}); + +test("S1 report follows values through array properties, unknown keys, callbacks and reflection", async () => { + const lines = await report(); + // a named property of an array, read back and leaked + expect(site(lines, /^site \{listed\}@/)).toMatch(/: P-escaping/); + // computed writes to an instance, a class and a function, read by name + expect(site(lines, /^site \{boxed\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{onClass\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{onFunction\}@/)).toMatch(/: P-escaping/); + // a JSON.parse reviver, a JSON.stringify replacer, and toJSON methods + expect(site(lines, /^site \{revived\}@/)).toMatch(/: P-escaping — escapes: stored into ⊤/); + expect(site(lines, /^site \{replaced\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{doc\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{owned\}@/)).toMatch(/: P-escaping/); + // a Proxy target and Reflect.deleteProperty + expect(site(lines, /^site \{proxied\}@/)).toMatch(/: G — proxy at/); + expect(site(lines, /^site \{gone\}@/)).toMatch(/: G — delete at/); + // a tagged template's substitution, passed to its tag + expect(site(lines, /^site \{part\}@/)).toMatch(/: P-escaping/); + // a named load of an array property written under an unknown key + expect(site(lines, /^site \{arrayComputed\}@/)).toMatch(/: P-escaping/); + // own fields written under unknown keys shadow a function's prototype and an instance's constructor + expect(site(lines, /^site \{onPrototype\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{onConstructor\}@/)).toMatch(/: P-escaping/); + // toJSON on an array, on a function, and stored under an unknown key + expect(site(lines, /^site \{inArray\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{inFunction\}@/)).toMatch(/: P-escaping/); + expect(site(lines, /^site \{underKey\}@/)).toMatch(/: P-escaping/); + // what a JSON.parse reviver returns is in the result + expect(site(lines, /^site \{fromReviver\}@/)).toMatch(/: P-escaping/); + // a proxy constructed through Reflect.construct and globalThis + expect(site(lines, /^site \{constructedProxy\}@/)).toMatch(/: G — proxy at/); + expect(site(lines, /^site \{globalProxy\}@/)).toMatch(/: G — proxy at/); + // the existing verdicts hold + expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/); +}); + +test("S1 report is off by default and changes nothing", async () => { + delete process.env["SCRIPTC_DEBUG_SHAPES"]; + const writes = vi.spyOn(process.stderr, "write"); + const entry = join(dir, "main.ts"); + await compile(entry, { + outDir: dir, + outPath: join(dir, "main.ir.json"), + outputKind: "ir", + npmStatic: ["shapes-fixture"], + }); + expect(writes.mock.calls.some(([c]) => String(c).includes("[shapes]"))).toBe(false); +}); diff --git a/packages/compiler/test/speculative-loop-emission.test.ts b/packages/compiler/test/speculative-loop-emission.test.ts index a81348102..3f6aef158 100644 --- a/packages/compiler/test/speculative-loop-emission.test.ts +++ b/packages/compiler/test/speculative-loop-emission.test.ts @@ -47,9 +47,8 @@ test("a failed check finishes its iteration outside the ordinary copy", async () const llvm = await emitLlvm(source); const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? ""; const order = labels(fn); - // The loop may have several speculative sites (one in each copy of a - // dense-array loop). Each site emits its ordinary copy, its third copy - // and its fast copy in that order. + // Each speculative site emits its ordinary copy, its third copy and its + // fast copy in that order. const slows = order.flatMap((l, i) => (l.startsWith("spec.slow") ? [i] : [])); expect(slows.length).toBeGreaterThan(0); let resumes = 0; @@ -91,3 +90,42 @@ test("a loop site stops entering its fast copy after repeated bails", async () = expect(fn).toMatch(new RegExp(`call i8 @llvm\\.uadd\\.sat\\.i8\\(i8 %\\S+, i8 1\\)`)); expect(fn).toContain(`store atomic i8 0, ptr ${site} unordered, align 1`); }); + +test("the slow copy of a dense-array loop has no speculative copy", async () => { + const llvm = await emitLlvm(source); + const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const order = labels(fn); + const slow = order.findIndex((l) => l.startsWith("dense.loop.slow")); + const fast = order.findIndex((l) => l.startsWith("dense.loop.fast")); + expect(slow).toBeGreaterThanOrEqual(0); + expect(fast).toBeGreaterThan(slow); + // The slow copy runs when the guard fails; only the fast copy speculates. + expect(order.slice(slow, fast).filter((l) => l.startsWith("spec."))).toEqual([]); + expect(order.slice(fast).some((l) => l.startsWith("spec.fast"))).toBe(true); + expect(llvm.match(/^@sc_spec_bails_\d+ = /gm)).toHaveLength(1); +}); + +test("the slow copy of a typed-array version keeps its proven integer loop", async () => { + // The program can start a worker, so typed-array loops are versioned on + // thread ownership. + const llvm = await emitLlvm(` +import { Worker, isMainThread } from "node:worker_threads"; +function count(bytes: Uint8Array): number { + let lines = 0; + for (let i = 0; i < bytes.length; i++) if (bytes[i] === 10) lines++; + return lines; +} +if (isMainThread) { + if (process.argv[2] === "spawn") new Worker(new URL(import.meta.url)); + console.log(count(new Uint8Array([10, 1, 10]))); +} +`); + const fn = /^define internal double @sc_bf_\w*count\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const order = labels(fn); + const slow = order.findIndex((l) => l.startsWith("bytes.version.slow")); + const fast = order.findIndex((l) => l.startsWith("bytes.version.fast")); + expect(slow).toBeGreaterThanOrEqual(0); + expect(fast).toBeGreaterThan(slow); + expect(order.slice(slow, fast).some((l) => l.startsWith("integer.leave"))).toBe(true); + expect(order.slice(fast).some((l) => l.startsWith("integer.leave"))).toBe(true); +}); diff --git a/packages/compiler/test/string-char-loop-emission.test.ts b/packages/compiler/test/string-char-loop-emission.test.ts index 6f5756ce8..9f2553d85 100644 --- a/packages/compiler/test/string-char-loop-emission.test.ts +++ b/packages/compiler/test/string-char-loop-emission.test.ts @@ -43,8 +43,12 @@ console.log(spaces("a b c"), letters("abC1z"), chars("xyz").join("-"), chars("é ll.match( new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{[\\s\\S]*?\\n\\}`), )![0]; - // The fast copy follows the checked one. - const fast = (fn: string): string => fn.slice(fn.lastIndexOf("bytes.version.fast")); + // The byte copy follows the checked one, and the units copy (for + // strings that are not ASCII) follows it. + const fast = (fn: string): string => { + const units = fn.search(/^bytes\.version\.fast\.units\d+:/m); + return fn.slice(fn.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units); + }; // `s[i] === " "` compares the byte; the checked copy keeps the helper. const spaces = body("spaces"); diff --git a/packages/compiler/test/string-unit-loop-emission.test.ts b/packages/compiler/test/string-unit-loop-emission.test.ts new file mode 100644 index 000000000..32ee8411b --- /dev/null +++ b/packages/compiler/test/string-unit-loop-emission.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("scans of strings that are not ASCII read their UTF-16 units", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-loops-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + ` +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} +function skip(s: string): number { + let pos = 0; + while (pos < s.length && s.charCodeAt(pos) !== 32) pos++; + return pos; +} +function pairs(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === t.charCodeAt(i % t.length)) n++; + return n; +} +let text = ""; +for (let k = 0; k < 400; k++) text += k % 3 === 0 ? "café " : "naïve façade "; +const other = text.replace(/a/g, "ä"); +let total = 0; +for (let pass = 0; pass < 4; pass++) + total += spaces(text) + spaces(other) + pairs(text, other) + skip(text); +console.log(total, spaces("plain ascii text"), spaces("")); +`, + ); + const outPath = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!lowered.ok) + throw new Error(lowered.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const body = (name: string): string => + ll.match( + new RegExp( + `define internal [^\\n]+ @sc_bf_${name}(?:\\.i32)?\\([^\\n]*\\) #0 \\{[\\s\\S]*?\\n\\}`, + ), + )![0]; + + const spaces = body("spaces"); + // A string that is not ASCII is published as the scan receiver once, + // before the loop, and its copy loads units. + expect(spaces).toMatch(/call double @scr_str_char_code_at\(ptr %\S+, double 0\.0\)/); + // The copies follow the checked one: bytes, then units. + const bytesAt = spaces.search(/^bytes\.version\.fast\d+:/m); + const unitsAt = spaces.search(/^bytes\.version\.fast\.units\d+:/m); + expect(bytesAt).toBeGreaterThan(0); + expect(unitsAt).toBeGreaterThan(bytesAt); + const units = spaces.slice(unitsAt); + expect(units).toMatch(/load i16, ptr %\S+\n\s+%\S+ = uitofp i16/); + expect(units).not.toContain("@sc_str_char_code_at_int"); + // An ASCII string still reads bytes. + const bytes = spaces.slice(bytesAt, unitsAt); + expect(bytes).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/); + expect(bytes).not.toContain("@sc_str_char_code_at_int"); + // A while loop over a counter below the length reads units too. + const skip = body("skip"); + expect(skip).toMatch(/^bytes\.version\.fast\.units\d+:/m); + // Its fast copies read the length the guard took; the checked copy + // still asks for it. + const skipFastAt = skip.search(/^bytes\.version\.fast\d+:/m); + expect(skip.slice(0, skipFastAt)).toContain("@sc_str_utf16_len"); + expect(skip.slice(skipFastAt)).not.toContain("@sc_str_utf16_len"); + // Two strings cannot both be the scan receiver. + expect(body("pairs")).not.toContain("bytes.version.fast.units"); + + const exe = join(dir, "main"); + const built = await compile(entry, { outputKind: "exe", outDir: dir, outPath: exe }); + if (!built.ok) throw new Error(JSON.stringify(built.diagnostics)); + let text = ""; + for (let k = 0; k < 400; k++) text += k % 3 === 0 ? "café " : "naïve façade "; + const other = text.replace(/a/g, "ä"); + const count = (s: string): number => s.split(" ").length - 1; + let pairs = 0; + for (let i = 0; i < text.length; i++) + if (text.charCodeAt(i) === other.charCodeAt(i % other.length)) pairs++; + const total = 4 * (count(text) + count(other) + pairs + text.indexOf(" ")); + expect(execFileSync(exe, { encoding: "utf8" })).toBe(`${total} 2 0\n`); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6307-number-map-get-set-fusion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6307-number-map-get-set-fusion.ts.json new file mode 100644 index 000000000..36c6b7a10 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6307-number-map-get-set-fusion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6307-number-map-get-set-fusion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json new file mode 100644 index 000000000..21b8255e4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7305-string-unit-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7305-string-unit-loop-versions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json new file mode 100644 index 000000000..cb5864986 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7307-string-unit-loop-evictions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7307-string-unit-loop-evictions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..e1a75c14e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7742-weak-collections-many-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7902-versioned-loop-slow-copies.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7902-versioned-loop-slow-copies.ts.json new file mode 100644 index 000000000..0147937af --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7902-versioned-loop-slow-copies.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7902-versioned-loop-slow-copies.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json new file mode 100644 index 000000000..f7c61e778 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7983-integer-compares.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7983-integer-compares.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7984-untyped-array-methods.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7984-untyped-array-methods.mjs.json new file mode 100644 index 000000000..2c823a24f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7984-untyped-array-methods.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7984-untyped-array-methods.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7993-json-parse-error-messages.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7993-json-parse-error-messages.ts.json new file mode 100644 index 000000000..4fec854a7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7993-json-parse-error-messages.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7993-json-parse-error-messages.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8002-while-byte-length-companions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8002-while-byte-length-companions.ts.json new file mode 100644 index 000000000..e2a36c22a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8002-while-byte-length-companions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8002-while-byte-length-companions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json new file mode 100644 index 000000000..5b23d8ecd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8020-date-setters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8020-date-setters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json new file mode 100644 index 000000000..176788025 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8021-date-coercions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8021-date-coercions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json new file mode 100644 index 000000000..ab8c55b7b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8022-date-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8022-date-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json new file mode 100644 index 000000000..934d260d8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8023-date-clone-throw.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8023-date-clone-throw.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json new file mode 100644 index 000000000..505f6305d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8024-date-setters-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8024-date-setters-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json new file mode 100644 index 000000000..f1c4d20e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8025-date-subclass-setters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8025-date-subclass-setters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json new file mode 100644 index 000000000..8a0fe9b5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8026-date-subclass-setters-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8026-date-subclass-setters-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json new file mode 100644 index 000000000..cfdfddfc6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8027-date-setter-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8027-date-setter-order.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json new file mode 100644 index 000000000..0a1b69c5f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8028-date-setter-order-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8028-date-setter-order-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json new file mode 100644 index 000000000..78ba99298 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8062-assert-message-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8062-assert-message-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json new file mode 100644 index 000000000..22edecf3f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8065-cjs-top-level-return/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8065-cjs-top-level-return/early.js", + "/tests/corpus/8065-cjs-top-level-return/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json new file mode 100644 index 000000000..bf862b8f2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8066-object-hasown-delete.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8066-object-hasown-delete.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json new file mode 100644 index 000000000..89f59e97c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8067-assert-throws-validators.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8067-assert-throws-validators.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json new file mode 100644 index 000000000..b4212e234 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8076-assert-instance-identity.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8076-assert-instance-identity.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json new file mode 100644 index 000000000..90af753d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8078-instanceof-object-function.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8078-instanceof-object-function.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8106-shared-typed-array-set.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8106-shared-typed-array-set.ts.json new file mode 100644 index 000000000..8b7a07fac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8106-shared-typed-array-set.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8106-shared-typed-array-set.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8107-shared-typed-array-set-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8107-shared-typed-array-set-workers.ts.json new file mode 100644 index 000000000..338b4bc03 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8107-shared-typed-array-set-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8107-shared-typed-array-set-workers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json new file mode 100644 index 000000000..730dd6360 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8190-assert-function-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8190-assert-function-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json new file mode 100644 index 000000000..0837dc2ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8191-assert-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8191-assert-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json new file mode 100644 index 000000000..7a3b97b6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8193-counted-loop-products.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8193-counted-loop-products.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json new file mode 100644 index 000000000..234bcebd5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8195-wrapping-products-near-2-53.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8195-wrapping-products-near-2-53.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json new file mode 100644 index 000000000..2b07cde52 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8232-native-streams-untyped.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8232-native-streams-untyped.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json new file mode 100644 index 000000000..a734fb48c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8233-string-decoder-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8233-string-decoder-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json new file mode 100644 index 000000000..7f37dbc5d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8234-handle-typeof.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8234-handle-typeof.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json new file mode 100644 index 000000000..6cf42e773 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8260-class-prototype-views.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8260-class-prototype-views.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8290-child-channel-members/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8290-child-channel-members/main.ts.json new file mode 100644 index 000000000..3519c4e6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8290-child-channel-members/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8290-child-channel-members/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-abort-controller-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-abort-controller-fields.ts.json new file mode 100644 index 000000000..80466c474 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-abort-controller-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-abort-controller-fields.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-mapped-alias-types.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-mapped-alias-types.ts.json new file mode 100644 index 000000000..08ec4c0f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-mapped-alias-types.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/empty-mapped-alias-types.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers-typed.ts.json new file mode 100644 index 000000000..fba491f13 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/instanceof-primitive-wrappers-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers.mjs.json new file mode 100644 index 000000000..e2dddae48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/instanceof-primitive-wrappers.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/instanceof-primitive-wrappers.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/untyped-push-emission.test.ts b/packages/compiler/test/untyped-push-emission.test.ts new file mode 100644 index 000000000..2abdf7f41 --- /dev/null +++ b/packages/compiler/test/untyped-push-emission.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +// An untyped push in statement position drops the new length, so it calls +// the entry that does not box it; a push whose result is read keeps the +// ordinary entry, as do the other methods. +test("a statement-position untyped push skips boxing its result", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-untyped-push-")); + try { + const entry = join(dir, "main.mjs"); + const output = join(dir, "main.ir.json"); + writeFileSync( + entry, + `function fill(a, n) { + for (let i = 0; i < n; i++) a.push(i); + const length = a.push(n); + a.pop(); + return length; +} +console.log(fill(JSON.parse("[]"), 3)); +`, + ); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const llvm = emitLlvmModule(module, {}); + const body = llvm.split(/\n(?=define )/).find((fn) => /@sc_[a-z]*f_fill\b/.test(fn))!; + const discarded = [...body.matchAll(/call void @scr_dyn_invoke_discard_id\(/g)]; + const kept = [...body.matchAll(/= call ptr @scr_dyn_invoke_prepared_id\(/g)]; + expect(discarded).toHaveLength(1); + // `a.push(n)` for its length, and `a.pop()`, whose result is dropped + // but which has no unboxed entry. + expect(kept).toHaveLength(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/while-induction-emission.test.ts b/packages/compiler/test/while-induction-emission.test.ts new file mode 100644 index 000000000..08fbab849 --- /dev/null +++ b/packages/compiler/test/while-induction-emission.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-while-induction-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { + outDir: dir, + outPath, + outputKind: "llvm", + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The definition holding the function's statements: the borrowed-argument + * body when there is one, otherwise the function itself. */ +function body(ll: string, name: string): string { + const definition = (prefix: string): number => + ll.search(new RegExp(`define [^\\n]*@${prefix}_${name}\\(`)); + const start = definition("sc_bf") >= 0 ? definition("sc_bf") : definition("sc_f"); + expect(start).toBeGreaterThanOrEqual(0); + return ll.slice(start, ll.indexOf("\n}\n", start)); +} + +test("a while loop bounded by a byte length keeps its counter and companions in integers", async () => { + const ll = await emitLlvm(` +function reversed(line: Uint8Array, target: Uint8Array): number { + const len = line.length; + let read = 0; + let written = 0; + let lines = 0; + while (read < len) { + const c = line[len - 1 - read]!; + read++; + if (c === 10) { + if (read === len) break; + read++; + } + target[written] = c; + written++; + if (written % 60 === 0) { + target[written] = 10; + written++; + lines++; + } + } + return written + lines; +} +console.log(reversed(new Uint8Array([65, 10, 66]), new Uint8Array(8))); +`); + const fn = body(ll, "reversed"); + expect(fn).toContain("alloca i64 ; integer read"); + expect(fn).toContain("alloca i64 ; integer written"); + expect(fn).toContain("alloca i64 ; integer lines"); +}); + +test("a companion a nested loop advances, or a counter a continue can skip, stays a double", async () => { + const ll = await emitLlvm(` +function nested(line: Uint8Array): number { + let read = 0; + let total = 0; + while (read < line.length) { + read++; + for (let j = 0; j < 2; j++) total++; + } + return total; +} +function skipped(line: Uint8Array): number { + let read = 0; + let total = 0; + while (read < line.length) { + if (line[read] === 0) continue; + read++; + total++; + } + return total; +} +console.log(nested(new Uint8Array(3)), skipped(new Uint8Array([1, 2]))); +`); + expect(body(ll, "nested")).not.toContain("; integer total"); + // The counter compared with the length directly is an integer. + expect(body(ll, "skipped")).toContain("; integer read"); + expect(body(ll, "skipped")).not.toContain("; integer total"); +}); diff --git a/packages/runtime-linux-arm64-gnu/scripts/build.mjs b/packages/runtime-linux-arm64-gnu/scripts/build.mjs index 729c3fbb6..d16fb1528 100644 --- a/packages/runtime-linux-arm64-gnu/scripts/build.mjs +++ b/packages/runtime-linux-arm64-gnu/scripts/build.mjs @@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: the static libgcc_eh, which loads no shared library. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "gcc_eh", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-arm64-musl/scripts/build.mjs b/packages/runtime-linux-arm64-musl/scripts/build.mjs index 10635d857..6c9b38244 100644 --- a/packages/runtime-linux-arm64-musl/scripts/build.mjs +++ b/packages/runtime-linux-arm64-musl/scripts/build.mjs @@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, targetArgs: ["-target", "aarch64-linux-musl"], compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: Zig's libunwind. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "unwind", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-x64-gnu/scripts/build.mjs b/packages/runtime-linux-x64-gnu/scripts/build.mjs index 811bb537b..53df5f7e7 100644 --- a/packages/runtime-linux-x64-gnu/scripts/build.mjs +++ b/packages/runtime-linux-x64-gnu/scripts/build.mjs @@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: the static libgcc_eh, which loads no shared library. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "gcc_eh", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-linux-x64-musl/scripts/build.mjs b/packages/runtime-linux-x64-musl/scripts/build.mjs index 33fc4c3d1..88538de76 100644 --- a/packages/runtime-linux-x64-musl/scripts/build.mjs +++ b/packages/runtime-linux-x64-musl/scripts/build.mjs @@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ }, targetArgs: ["-target", "x86_64-linux-musl"], compileFlags: ["-ffunction-sections", "-fdata-sections"], - systemLibraries: [{ name: "m", predicate: true }], + // scr_unwind_raise: Zig's libunwind. + systemLibraries: [ + { name: "m", predicate: true }, + { name: "unwind", predicate: true }, + ], }); await import("../../runtime-pack-common/scripts/build.mjs"); diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 600637ba6..4df534739 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -30,6 +30,7 @@ export const BASE_RUNTIME_SOURCES = [ "scr_object.c", "scr_union.c", "scr_exception.c", + "scr_unwind.c", "scr_error.c", "scr_console.c", "scr_lib.c", diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 40e0580a4..2c3860432 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -1972,6 +1972,14 @@ static void scr_frozen_undeletable(double index) { scr_frozen_throw("Cannot delete property '", index, "' of [object Array]"); } +void scr_array_not_extensible_error(double index) { + scr_frozen_not_extensible(index); +} + +void scr_array_undeletable_error(double index) { + scr_frozen_undeletable(index); +} + /* Each case finds the operation's first write in the order the spec makes * its writes. */ bool scr_array_frozen_error(int op, size_t len, double x, double y, double z) { diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 84057ffc9..f738c15b1 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -255,6 +255,10 @@ bool scr_arr_is_frozen(const ScrArr *a); * end), 9 copyWithin (x = target, y = start, z = end), 10 splice (x = * start, y = delete count, z = item count). */ bool scr_array_frozen_error(int op, size_t len, double x, double y, double z); +/* Node's TypeErrors for a write that a non-extensible or sealed array + * refuses: adding the element at `index`, or deleting it. */ +void scr_array_not_extensible_error(double index); +void scr_array_undeletable_error(double index); /* The start of the dense slots' allocation: `data` less any head room. */ static inline uint64_t *scr_arr_block(const ScrArr *a) { diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 4917b1291..bf5d1c875 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -212,7 +212,56 @@ bool scr_assert_skip_prototype(void) { return (g_instance_options & SCR_ASSERT_SKIP_PROTOTYPE) != 0; } +/* A message argument that is not a string (an untyped value, or an Error + * to throw): the compiled call registers it before the assertion and passes + * SCR_ASSERT_VALUE_MESSAGE as its message, which the failure paths compose + * like any custom message. A failure whose message starts with it throws + * the Error itself (innerFail's `message instanceof Error`) or puts + * String(value) in its place with generatedMessage `!value`, as Node's + * AssertionError does. Matching the sentinel keeps a registration that no + * failure consumed from reaching another assertion. */ +static SCR_TL ScrDyn *g_message_value; +static SCR_TL bool g_message_error; + +void scr_assert_message_value(ScrDyn *value, bool error) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = scr_dyn_retain(value); + g_message_error = error; +} + +void scr_assert_message_clear(void) { + if (g_message_value) scr_dyn_release(g_message_value); + g_message_value = NULL; +} + void scr_assert_fail(ScrStr *message, bool generated) { + static const char sentinel[] = SCR_ASSERT_VALUE_MESSAGE; + const size_t n = sizeof sentinel - 1; + if (g_message_value && message->len >= n && memcmp(message->data, sentinel, n) == 0) { + ScrDyn *value = g_message_value; + g_message_value = NULL; + if (g_message_error) { + scr_str_release(message); + scr_assert_record(NULL, NULL, NULL); + g_instance_options = 0; + scr_dyn_throw(value); + return; + } + ScrStr *text = scr_dyn_string_coerce_js(value); + generated = !scr_dyn_truthy(value); + scr_dyn_release(value); + if (!text) { + scr_str_release(message); + return; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, text->data, text->len); + scr_jb_write(&b, message->data + n, message->len - n); + scr_str_release(text); + scr_str_release(message); + message = scr_jb_finish(&b); + } ScrError *e = scr_assert_error(message, generated, scr_assert_diff_full() ? "full" : "simple"); g_instance_options = 0; scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); @@ -2703,6 +2752,60 @@ bool scr_assert_no_error_predicate(ScrDyn *predicate, ScrDyn *actual) { return matched; } +/* assert.throws/rejects with a validation function (expectedException's + * function branch, after its instanceof check): the function runs with a + * fresh object as `this` and the caught value, and any result but true + * fails, with Node's generated message unless the call passed one. The + * caught value and the function are the AssertionError's actual and + * expected. Borrows all. */ +void scr_assert_throws_validate(ScrDyn *actual, ScrDyn *validate, ScrStr *op, ScrStr *msg, + bool has_msg) { + ScrDyn *receiver = scr_dyn_new_obj(); + scr_dyn_this_push_dyn(receiver); + ScrDyn *result = scr_dyn_call(validate, &actual, 1, "expected"); + scr_dyn_this_pop(); + scr_dyn_release(receiver); + if (!result) return; + if (result->kind == SCR_DYN_BOOL && result->v.b) { + scr_dyn_release(result); + return; + } + ScrStr *message; + bool generated = !(has_msg && msg->len > 0); + if (!generated) { + message = scr_str_retain(msg); + } else { + ScrAssertBuf b = {0}; + ab_cstr(&b, "The "); + ScrDyn *name = scr_dyn_fn_get(validate, "name", 4); + if (name && name->kind == SCR_DYN_STR && name->v.str->len > 0) { + ab_cstr(&b, "\""); + ab_bytes(&b, name->v.str->data, name->v.str->len); + ab_cstr(&b, "\" "); + } + if (name) scr_dyn_release(name); + ab_cstr(&b, "validation function is expected to return \"true\". Received "); + ScrStr *shown = scr_assert_cf_inspect(result); + ab_bytes(&b, shown->data, shown->len); + scr_str_release(shown); + if (scr_dyn_ref_is_error(actual)) { + ScrStr *text = scr_dyn_string_coerce_js(actual); + if (!text) { + scr_dyn_release(result); + scr_str_release(ab_take(&b)); + return; + } + ab_cstr(&b, "\n\nCaught error:\n\n"); + ab_bytes(&b, text->data, text->len); + scr_str_release(text); + } + message = ab_take(&b); + } + scr_dyn_release(result); + scr_assert_record_dyn(actual, validate, op); + scr_assert_fail(message, generated); +} + /* ── assert.ifError ─────────────────────────────────────────────────── * Node throws for ANY value but null/undefined (falsy included); the * frontend routes null/undefined to a no-op and everything else here. @@ -2879,3 +2982,225 @@ ScrError *scr_assert_error_new(ScrDyn *options) { scr_assert_attach_values(e, generated, actual, expected, op, diff); return e; } + +/* ── assert functions as values ────────────────────────────────────────── + * `assert.fail` stored or passed as a function (`emitter.on("x", + * assert.fail)`): one function per method, which takes its arguments as + * untyped values and asserts as the method does. */ + +/* Node's message argument: absent or null/undefined (a generated message), + * a string, an Error (thrown when the assertion fails), or another value + * (String(value)). The value is registered as scr_assert_fail reads it; the + * caller clears it after the assertion. +1 into *msg. */ +static void scr_av_message(ScrDyn *const *args, size_t argc, size_t i, ScrStr **msg, bool *has) { + const ScrDyn *m = i < argc ? args[i] : NULL; + if (!m || m->kind == SCR_DYN_UNDEF || m->kind == SCR_DYN_NULL) { + *msg = scr_str_new("", 0); + *has = false; + return; + } + *has = true; + if (m->kind == SCR_DYN_STR) { + *msg = scr_str_retain(m->v.str); + return; + } + bool error = scr_dyn_ref_is_error(m) || + (m->kind == SCR_DYN_OBJ && scr_dyn_obj_get(m, "%error", 6) != NULL); + scr_assert_message_value((ScrDyn *)m, error); + *msg = scr_str_new(SCR_ASSERT_VALUE_MESSAGE, sizeof SCR_ASSERT_VALUE_MESSAGE - 1); +} + +static const ScrDyn *scr_av_arg(ScrDyn *const *args, size_t argc, size_t i) { + return i < argc ? args[i] : scr_dyn_undefined(); +} + +static ScrDyn *scr_av_done(ScrStr *msg) { + scr_str_release(msg); + scr_assert_message_clear(); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* assert(value, message) and assert.ok: Node's generated message quotes + * the call's source, which a function value has no fixed site for; the + * message here is AssertionError's own `value == true`. */ +static ScrDyn *scr_av_ok(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + const ScrDyn *value = scr_av_arg(args, argc, 0); + if (scr_dyn_truthy(value)) return scr_dyn_retain(scr_dyn_undefined()); + scr_assert_record(scr_dyn_retain((ScrDyn *)value), scr_dyn_new_bool(true), "=="); + if (argc == 0) { + static const char text[] = "No value argument passed to `assert.ok()`"; + scr_assert_fail(scr_str_new(text, sizeof text - 1), true); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 1, &msg, &has); + if (has) { + scr_assert_fail(scr_str_retain(msg), false); + return scr_av_done(msg); + } + ScrStr *shown = scr_assert_cf_inspect(value); + ScrAssertBuf b = {0}; + ab_str(&b, shown); + ab_cstr(&b, " == true"); + scr_str_release(shown); + scr_assert_fail(ab_take(&b), true); + return scr_av_done(msg); +} + +/* assert.fail(message): "Failed" without a message. */ +static ScrDyn *scr_av_fail(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + if (argc > 1) { + static const char text[] = "assert.fail with more than one argument through a function value is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, text, sizeof text - 1, "SC2020"); + return NULL; + } + ScrStr *msg; + bool has; + scr_av_message(args, argc, 0, &msg, &has); + scr_assert_record(scr_dyn_retain(scr_dyn_undefined()), scr_dyn_retain(scr_dyn_undefined()), "fail"); + if (has) scr_assert_fail(scr_str_retain(msg), false); + else scr_assert_fail(scr_str_new("Failed", 6), true); + return scr_av_done(msg); +} + +/* The comparison methods take both operands: Node's ERR_MISSING_ARGS + * TypeError otherwise. False with it pending. */ +static bool scr_av_operands(size_t argc) { + if (argc >= 2) return true; + static const char text[] = "The \"actual\" and \"expected\" arguments must be specified"; + scr_throw_error_msg_code(SCR_ERR_TYPE, text, sizeof text - 1, "ERR_MISSING_ARGS"); + return false; +} + +/* equal / notEqual: Node's loose comparison, NaN equal to NaN. */ +static ScrDyn *scr_av_loose(ScrDyn *const *args, size_t argc, bool negated) { + if (!scr_av_operands(argc)) return NULL; + const ScrDyn *a = scr_av_arg(args, argc, 0), *b = scr_av_arg(args, argc, 1); + bool nan = a->kind == SCR_DYN_NUM && b->kind == SCR_DYN_NUM && isnan(a->v.num) && isnan(b->v.num); + bool equal = nan || scr_dyn_abstract_eq(a, b); + if (scr_exc_pending()) return NULL; + if (negated ? !equal : equal) return scr_dyn_retain(scr_dyn_undefined()); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + scr_assert_record(scr_dyn_retain((ScrDyn *)a), scr_dyn_retain((ScrDyn *)b), negated ? "!=" : "=="); + ScrStr *ia = scr_assert_cf_inspect(a), *ib = scr_assert_cf_inspect(b); + scr_assert_loose_result(equal, negated, ia, ib, msg, has); + scr_str_release(ia); + scr_str_release(ib); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, false); +} +static ScrDyn *scr_av_not_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_loose(a, n, true); +} + +/* The strict and deep comparisons, through the untyped assert paths. */ +enum { SCR_AV_STRICT, SCR_AV_DEEP_STRICT, SCR_AV_DEEP, SCR_AV_PARTIAL }; + +static ScrDyn *scr_av_compare(ScrDyn *const *args, size_t argc, int form, bool negated) { + if (!scr_av_operands(argc)) return NULL; + ScrDyn *a = (ScrDyn *)scr_av_arg(args, argc, 0), *b = (ScrDyn *)scr_av_arg(args, argc, 1); + ScrStr *msg; + bool has; + scr_av_message(args, argc, 2, &msg, &has); + if (form == SCR_AV_DEEP) scr_assert_loose_deep_dyn(a, b, negated, msg, has); + else if (form == SCR_AV_PARTIAL) scr_assert_partial_deep_dyn(a, b, msg, has); + else scr_assert_eq_dyn(a, b, negated, form == SCR_AV_DEEP_STRICT, msg, has); + return scr_av_done(msg); +} + +static ScrDyn *scr_av_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, false); +} +static ScrDyn *scr_av_not_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_STRICT, true); +} +static ScrDyn *scr_av_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, false); +} +static ScrDyn *scr_av_not_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP_STRICT, true); +} +static ScrDyn *scr_av_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, false); +} +static ScrDyn *scr_av_not_deep_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_DEEP, true); +} +static ScrDyn *scr_av_partial_deep_strict_equal(ScrClosure *c, ScrDyn *const *a, size_t n) { + (void)c; + return scr_av_compare(a, n, SCR_AV_PARTIAL, false); +} + +static ScrDyn *scr_av_if_error(ScrClosure *c, ScrDyn *const *args, size_t argc) { + (void)c; + scr_assert_iferror_dyn(scr_av_arg(args, argc, 0)); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static const struct { + const char *name; + ScrDynThunk thunk; + uint32_t arity; +} scr_av_methods[] = { + {"ok", &scr_av_ok, 0}, + {"fail", &scr_av_fail, 1}, + {"equal", &scr_av_equal, 3}, + {"notEqual", &scr_av_not_equal, 3}, + {"strictEqual", &scr_av_strict_equal, 3}, + {"notStrictEqual", &scr_av_not_strict_equal, 3}, + {"deepEqual", &scr_av_deep_equal, 3}, + {"notDeepEqual", &scr_av_not_deep_equal, 3}, + {"deepStrictEqual", &scr_av_deep_strict_equal, 3}, + {"notDeepStrictEqual", &scr_av_not_deep_strict_equal, 3}, + {"partialDeepStrictEqual", &scr_av_partial_deep_strict_equal, 3}, + {"ifError", &scr_av_if_error, 1}, +}; +#define SCR_AV_COUNT (sizeof scr_av_methods / sizeof scr_av_methods[0]) +static SCR_TL ScrDyn *scr_av_values[SCR_AV_COUNT]; +static SCR_TL bool scr_av_registered; + +static void scr_av_cleanup(void) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + scr_dyn_release(scr_av_values[i]); + scr_av_values[i] = NULL; + } + scr_av_registered = false; +} + +/* The function value of an assert method by its canonical name (the frontend + * maps assert/strict's aliases), +1; one value per method, so stored copies + * compare equal. */ +ScrDyn *scr_assert_method_value(ScrStr *name) { + for (size_t i = 0; i < SCR_AV_COUNT; i++) { + if (strlen(scr_av_methods[i].name) != name->len || + memcmp(scr_av_methods[i].name, name->data, name->len) != 0) + continue; + if (!scr_av_values[i]) { + if (!scr_av_registered) { + scr_av_registered = true; + scr_atexit(scr_av_cleanup); + } + scr_av_values[i] = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_av_methods[i].thunk, + scr_av_methods[i].arity, "native:assert", + scr_av_methods[i].name); + } + return scr_dyn_retain(scr_av_values[i]); + } + return scr_dyn_retain(scr_dyn_undefined()); +} diff --git a/packages/runtime/src/scr_assert.h b/packages/runtime/src/scr_assert.h index 8037f08ec..57158a69a 100644 --- a/packages/runtime/src/scr_assert.h +++ b/packages/runtime/src/scr_assert.h @@ -24,6 +24,19 @@ void scr_assert_fail(ScrStr *message, bool generated); /* takes ownership; alway * consumes them; a passing call clears them. */ void scr_assert_set_options(int options); void scr_assert_clear_options(void); +/* A message argument that is not a string, for the next assertion that + * fails with SCR_ASSERT_VALUE_MESSAGE as its message (scr_assert.c): an + * Error (`error`) is thrown as it is, anything else reports String(value). + * Borrows value; clear drops a registration the call did not consume. */ +#define SCR_ASSERT_VALUE_MESSAGE "\x01scriptc:assert-message-value" +void scr_assert_message_value(struct ScrDyn *value, bool error); +void scr_assert_message_clear(void); +/* An assert method as a function value, by canonical name (+1). */ +struct ScrDyn *scr_assert_method_value(ScrStr *name); +/* assert.throws/rejects with a validation function: its result must be + * true (Node's expectedException), else the AssertionError. Borrows all. */ +void scr_assert_throws_validate(struct ScrDyn *actual, struct ScrDyn *validate, ScrStr *op, + ScrStr *msg, bool has_msg); bool scr_assert_diff_full(void); bool scr_assert_skip_prototype(void); void scr_assert_record(ScrDyn *actual, ScrDyn *expected, const char *op); /* values move in */ diff --git a/packages/runtime/src/scr_bigint.c b/packages/runtime/src/scr_bigint.c index dd0a70ff7..69b7009fc 100644 --- a/packages/runtime/src/scr_bigint.c +++ b/packages/runtime/src/scr_bigint.c @@ -215,30 +215,89 @@ static int bi_cmp_limbs(const bi_limb *a, size_t an, const bi_limb *b, size_t bn return 0; } -/* r = a + b over n limbs; returns the carry. r may alias a or b. */ +/* x + y + carry_in (0 or 1), with the carry out in *carry: the add-with- + * carry builtins compile to one adc chain. */ +#if defined(__has_builtin) +#if __has_builtin(__builtin_addcll) && __has_builtin(__builtin_subcll) && \ + __has_builtin(__builtin_addc) && __has_builtin(__builtin_subc) +#define BI_HAVE_ADDC 1 +#endif +#endif +static inline bi_limb bi_addc(bi_limb x, bi_limb y, bi_limb *carry) { +#if defined(BI_HAVE_ADDC) && BI_LIMB_BITS == 64 + unsigned long long c; + bi_limb s = __builtin_addcll(x, y, *carry, &c); + *carry = c; + return s; +#elif defined(BI_HAVE_ADDC) + unsigned c; + bi_limb s = __builtin_addc(x, y, *carry, &c); + *carry = c; + return s; +#else + bi_limb s = x + y; + bi_limb c1 = s < x; + bi_limb t = s + *carry; + *carry = c1 | (t < s); + return t; +#endif +} + +/* x - y - borrow_in (0 or 1), with the borrow out in *borrow. */ +static inline bi_limb bi_subc(bi_limb x, bi_limb y, bi_limb *borrow) { +#if defined(BI_HAVE_ADDC) && BI_LIMB_BITS == 64 + unsigned long long b; + bi_limb d = __builtin_subcll(x, y, *borrow, &b); + *borrow = b; + return d; +#elif defined(BI_HAVE_ADDC) + unsigned b; + bi_limb d = __builtin_subc(x, y, *borrow, &b); + *borrow = b; + return d; +#else + bi_limb d = x - y; + bi_limb b1 = x < y; + bi_limb t = d - *borrow; + *borrow = b1 | (d < *borrow); + return t; +#endif +} + +/* r = a + b over n limbs; returns the carry. r may alias a or b. Four + * limbs a pass keep the carry in one chain. */ static bi_limb bi_add_n(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n) { bi_limb carry = 0; - for (size_t i = 0; i < n; i++) { - bi_limb x = a[i], s = x + b[i]; - bi_limb c1 = s < x; - bi_limb t = s + carry; - carry = c1 | (t < s); - r[i] = t; + size_t i = 0; + for (; i + 8 <= n; i += 8) { + r[i] = bi_addc(a[i], b[i], &carry); + r[i + 1] = bi_addc(a[i + 1], b[i + 1], &carry); + r[i + 2] = bi_addc(a[i + 2], b[i + 2], &carry); + r[i + 3] = bi_addc(a[i + 3], b[i + 3], &carry); + r[i + 4] = bi_addc(a[i + 4], b[i + 4], &carry); + r[i + 5] = bi_addc(a[i + 5], b[i + 5], &carry); + r[i + 6] = bi_addc(a[i + 6], b[i + 6], &carry); + r[i + 7] = bi_addc(a[i + 7], b[i + 7], &carry); } + for (; i < n; i++) r[i] = bi_addc(a[i], b[i], &carry); return carry; } /* r = a - b over n limbs; returns the borrow. r may alias a or b. */ static bi_limb bi_sub_n(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n) { bi_limb borrow = 0; - for (size_t i = 0; i < n; i++) { - bi_limb x = a[i], y = b[i]; - bi_limb d = x - y; - bi_limb b1 = x < y; - bi_limb t = d - borrow; - borrow = b1 | (d < borrow); - r[i] = t; + size_t i = 0; + for (; i + 8 <= n; i += 8) { + r[i] = bi_subc(a[i], b[i], &borrow); + r[i + 1] = bi_subc(a[i + 1], b[i + 1], &borrow); + r[i + 2] = bi_subc(a[i + 2], b[i + 2], &borrow); + r[i + 3] = bi_subc(a[i + 3], b[i + 3], &borrow); + r[i + 4] = bi_subc(a[i + 4], b[i + 4], &borrow); + r[i + 5] = bi_subc(a[i + 5], b[i + 5], &borrow); + r[i + 6] = bi_subc(a[i + 6], b[i + 6], &borrow); + r[i + 7] = bi_subc(a[i + 7], b[i + 7], &borrow); } + for (; i < n; i++) r[i] = bi_subc(a[i], b[i], &borrow); return borrow; } @@ -277,9 +336,22 @@ static bi_limb bi_sub_into(bi_limb *r, size_t rn, const bi_limb *a, size_t an) { return borrow; } -/* r = a * m + carry over n limbs; returns the high limb. r may alias a. */ +/* r = a * m + carry over n limbs; returns the high limb. r may alias a. + * Four limbs a pass: the four products, then one carry chain adding each + * product's high limb into the next low limb. */ static bi_limb bi_mul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m, bi_limb carry) { - for (size_t i = 0; i < n; i++) { + size_t i = 0; + for (; i + 4 <= n; i += 4) { + bi_wide p0 = (bi_wide)a[i] * m, p1 = (bi_wide)a[i + 1] * m; + bi_wide p2 = (bi_wide)a[i + 2] * m, p3 = (bi_wide)a[i + 3] * m; + bi_limb c = 0; + r[i] = bi_addc((bi_limb)p0, carry, &c); + r[i + 1] = bi_addc((bi_limb)p1, (bi_limb)(p0 >> BI_LIMB_BITS), &c); + r[i + 2] = bi_addc((bi_limb)p2, (bi_limb)(p1 >> BI_LIMB_BITS), &c); + r[i + 3] = bi_addc((bi_limb)p3, (bi_limb)(p2 >> BI_LIMB_BITS), &c); + carry = (bi_limb)(p3 >> BI_LIMB_BITS) + c; + } + for (; i < n; i++) { bi_wide p = (bi_wide)a[i] * m + carry; r[i] = (bi_limb)p; carry = (bi_limb)(p >> BI_LIMB_BITS); @@ -287,10 +359,27 @@ static bi_limb bi_mul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m, bi_lim return carry; } -/* r += a * m over n limbs; returns the high limb. */ +/* r += a * m over n limbs; returns the high limb. Four limbs a pass: one + * carry chain forms the four product limbs, a second adds them to r. */ static bi_limb bi_addmul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m) { bi_limb carry = 0; - for (size_t i = 0; i < n; i++) { + size_t i = 0; + for (; i + 4 <= n; i += 4) { + bi_wide p0 = (bi_wide)a[i] * m, p1 = (bi_wide)a[i + 1] * m; + bi_wide p2 = (bi_wide)a[i + 2] * m, p3 = (bi_wide)a[i + 3] * m; + bi_limb c = 0, k = 0; + bi_limb t0 = bi_addc((bi_limb)p0, carry, &c); + bi_limb t1 = bi_addc((bi_limb)p1, (bi_limb)(p0 >> BI_LIMB_BITS), &c); + bi_limb t2 = bi_addc((bi_limb)p2, (bi_limb)(p1 >> BI_LIMB_BITS), &c); + bi_limb t3 = bi_addc((bi_limb)p3, (bi_limb)(p2 >> BI_LIMB_BITS), &c); + r[i] = bi_addc(r[i], t0, &k); + r[i + 1] = bi_addc(r[i + 1], t1, &k); + r[i + 2] = bi_addc(r[i + 2], t2, &k); + r[i + 3] = bi_addc(r[i + 3], t3, &k); + /* r + a * m over these limbs stays below B^5, so this cannot wrap */ + carry = (bi_limb)(p3 >> BI_LIMB_BITS) + c + k; + } + for (; i < n; i++) { bi_wide p = (bi_wide)a[i] * m + r[i] + carry; r[i] = (bi_limb)p; carry = (bi_limb)(p >> BI_LIMB_BITS); @@ -301,7 +390,23 @@ static bi_limb bi_addmul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m) { /* r -= a * m over n limbs; returns the borrow limb. */ static bi_limb bi_submul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m) { bi_limb carry = 0; - for (size_t i = 0; i < n; i++) { + size_t i = 0; + for (; i + 4 <= n; i += 4) { + bi_wide p0 = (bi_wide)a[i] * m, p1 = (bi_wide)a[i + 1] * m; + bi_wide p2 = (bi_wide)a[i + 2] * m, p3 = (bi_wide)a[i + 3] * m; + bi_limb c = 0, k = 0; + bi_limb t0 = bi_addc((bi_limb)p0, carry, &c); + bi_limb t1 = bi_addc((bi_limb)p1, (bi_limb)(p0 >> BI_LIMB_BITS), &c); + bi_limb t2 = bi_addc((bi_limb)p2, (bi_limb)(p1 >> BI_LIMB_BITS), &c); + bi_limb t3 = bi_addc((bi_limb)p3, (bi_limb)(p2 >> BI_LIMB_BITS), &c); + r[i] = bi_subc(r[i], t0, &k); + r[i + 1] = bi_subc(r[i + 1], t1, &k); + r[i + 2] = bi_subc(r[i + 2], t2, &k); + r[i + 3] = bi_subc(r[i + 3], t3, &k); + /* a * m + carry over these limbs stays below B^5: the borrow fits */ + carry = (bi_limb)(p3 >> BI_LIMB_BITS) + c + k; + } + for (; i < n; i++) { bi_wide p = (bi_wide)a[i] * m + carry; bi_limb lo = (bi_limb)p; carry = (bi_limb)(p >> BI_LIMB_BITS); @@ -312,6 +417,23 @@ static bi_limb bi_submul1(bi_limb *r, const bi_limb *a, size_t n, bi_limb m) { return carry; } +/* y[0..n) = the low n limbs of x << shift, for 0 < shift < BI_LIMB_BITS + * and y disjoint from x. Each limb is formed from two source limbs with no + * dependency between limbs, so the loop vectorizes. */ +static void bi_lshift(bi_limb *restrict y, const bi_limb *restrict x, size_t n, unsigned shift) { + unsigned back = BI_LIMB_BITS - shift; + y[0] = x[0] << shift; + for (size_t i = 1; i < n; i++) y[i] = (x[i] << shift) | (x[i - 1] >> back); +} + +/* y[0..n) = x[0..n) >> shift, for 0 < shift < BI_LIMB_BITS and y disjoint + * from x; like bi_lshift, each limb reads two source limbs. */ +static void bi_rshift(bi_limb *restrict y, const bi_limb *restrict x, size_t n, unsigned shift) { + unsigned back = BI_LIMB_BITS - shift; + for (size_t i = 0; i + 1 < n; i++) y[i] = (x[i] >> shift) | (x[i + 1] << back); + y[n - 1] = x[n - 1] >> shift; +} + /* Schoolbook product: r[0..an+bn) = a * b, r disjoint from a and b. */ static void bi_mul_school(bi_limb *r, const bi_limb *a, size_t an, const bi_limb *b, size_t bn) { r[an] = bi_mul1(r, a, an, b[0], 0); @@ -492,11 +614,9 @@ static void bi_divmod_knuth(bi_limb *q, bi_limb *r, const bi_limb *a, size_t an, bi_limb *un = bi_limbs(an + 1 + bn); bi_limb *vn = un + an + 1; if (s) { - for (size_t i = bn - 1; i > 0; i--) vn[i] = (b[i] << s) | (b[i - 1] >> (BI_LIMB_BITS - s)); - vn[0] = b[0] << s; + bi_lshift(vn, b, bn, s); + bi_lshift(un, a, an, s); un[an] = a[an - 1] >> (BI_LIMB_BITS - s); - for (size_t i = an - 1; i > 0; i--) un[i] = (a[i] << s) | (a[i - 1] >> (BI_LIMB_BITS - s)); - un[0] = a[0] << s; } else { memcpy(vn, b, bn * sizeof(bi_limb)); memcpy(un, a, an * sizeof(bi_limb)); @@ -534,8 +654,7 @@ static void bi_divmod_knuth(bi_limb *q, bi_limb *r, const bi_limb *a, size_t an, } if (r) { if (s) { - for (size_t i = 0; i + 1 < bn; i++) r[i] = (un[i] >> s) | (un[i + 1] << (BI_LIMB_BITS - s)); - r[bn - 1] = un[bn - 1] >> s; + bi_rshift(r, un, bn, s); } else { memcpy(r, un, bn * sizeof(bi_limb)); } @@ -543,6 +662,65 @@ static void bi_divmod_knuth(bi_limb *q, bi_limb *r, const bi_limb *a, size_t an, free(un); } +/* a / b for a and b of n >= 2 limbs each, b's top limb nonzero: the + * quotient is one limb. Knuth's estimate comes from the top limbs as + * they read normalized (three of a, two of b) and is the quotient or one + * more, which one pass of a - qhat * b over the operands as they are + * settles, with no normalized copies. r, unless NULL, receives the n + * limbs of a % b. */ +static bi_limb bi_div_qlimb(bi_limb *r, const bi_limb *a, const bi_limb *b, size_t n) { + unsigned s = bi_clz(b[n - 1]); + bi_limb d1 = b[n - 1], d0 = b[n - 2], u2 = 0, u1 = a[n - 1], u0 = a[n - 2]; + if (s) { + unsigned t = BI_LIMB_BITS - s; + bi_limb b3 = n >= 3 ? b[n - 3] : 0, a3 = n >= 3 ? a[n - 3] : 0; + d1 = (d1 << s) | (d0 >> t); + d0 = (d0 << s) | (b3 >> t); + u2 = u1 >> t; + u1 = (u1 << s) | (u0 >> t); + u0 = (u0 << s) | (a3 >> t); + } + bi_limb qhat, rhat; + bool rhat_big; /* rhat >= B: the estimate needs no further test */ + if (u2 >= d1) { + qhat = BI_LIMB_MAX; + rhat = u1 + d1; + rhat_big = rhat < d1; + } else { + bi_divisor top = bi_divisor_of(d1); + qhat = bi_div21(u2, u1, top.d, top.v, &rhat); + rhat_big = false; + } + while (!rhat_big && (bi_wide)qhat * d0 > (((bi_wide)rhat << BI_LIMB_BITS) | u0)) { + qhat--; + rhat += d1; + rhat_big = rhat < d1; + } + /* Without a remainder, the top limbs decide when they leave room: + * a - qhat * b >= (rhat * B + u0 - qhat * d0) * B^(n-2) - qhat * B^(n-2) + * as normalized, so a margin of at least qhat proves qhat. */ + if (!r && !rhat_big) { + bi_wide top = ((bi_wide)rhat << BI_LIMB_BITS) | u0, sub = (bi_wide)qhat * d0; + if (top - sub >= qhat) return qhat; + } else if (!r) { + return qhat; /* rhat >= B: the remainder exceeds B^(n-1) >= qhat * b's low part */ + } + /* a - qhat * b: negative when the product's high limb or the borrow + * reaches past a's n limbs */ + bi_limb carry = 0, borrow = 0; + for (size_t i = 0; i < n; i++) { + bi_wide p = (bi_wide)b[i] * qhat + carry; + carry = (bi_limb)(p >> BI_LIMB_BITS); + bi_limb d = bi_subc(a[i], (bi_limb)p, &borrow); + if (r) r[i] = d; + } + if (carry || borrow) { + qhat--; + if (r) bi_add_n(r, r, b, n); + } + return qhat; +} + /* ---- comparisons and hashing ----------------------------------------- */ static int bi_cmp_abs(const ScrBigInt *a, const ScrBigInt *b) { @@ -709,6 +887,13 @@ static void bi_divmod_abs(const ScrBigInt *a, const ScrBigInt *b, ScrBigInt **qo if (rout) *rout = bi_u64_signed(rem, 1); return; } + if (an == bn) { + ScrBigInt *r = rout ? bi_alloc(bn) : NULL; + bi_limb q = bi_div_qlimb(r ? r->limb : NULL, a->limb, b->limb, bn); + if (qout) *qout = bi_u64_signed(q, 1); + if (rout) *rout = bi_finish(r, bn, 1); + return; + } size_t qn = an - bn + 1; ScrBigInt *q = bi_alloc(qn); ScrBigInt *r = rout ? bi_alloc(bn) : NULL; @@ -772,13 +957,8 @@ static ScrBigInt *bi_shl_abs(const ScrBigInt *a, size_t count, int sign) { if (!shift) { memcpy(r->limb + words, a->limb, a->len * sizeof(bi_limb)); } else { - bi_limb carry = 0; - for (size_t i = 0; i < a->len; i++) { - bi_limb v = a->limb[i]; - r->limb[i + words] = (v << shift) | carry; - carry = v >> (BI_LIMB_BITS - shift); - } - if (grow) r->limb[n] = carry; + bi_lshift(r->limb + words, a->limb, a->len, shift); + if (grow) r->limb[n] = a->limb[a->len - 1] >> (BI_LIMB_BITS - shift); } r->len = n + grow; r->sign = sign; @@ -801,9 +981,7 @@ static ScrBigInt *bi_shr_abs(const ScrBigInt *a, size_t count, int sign, bool *d if (!shift) { memcpy(r->limb, a->limb + words, n * sizeof(bi_limb)); } else { - for (size_t i = 0; i + 1 < n; i++) - r->limb[i] = (a->limb[i + words] >> shift) | (a->limb[i + words + 1] << (BI_LIMB_BITS - shift)); - r->limb[n - 1] = a->limb[a->len - 1] >> shift; + bi_rshift(r->limb, a->limb + words, n, shift); } return bi_finish(r, n, sign); } diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 3926bf83e..e6df837c8 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -755,12 +755,29 @@ void scr_bytes_set_from(ScrBytes *dst, const ScrBytes *src, double offset) { } size_t width = scr_bytes_elem_size(dst->elem); if (dst->shared || src->shared) { - /* Convert a private copy of the source, then publish the target range. */ SCR_BYTES_SNAPSHOT(src); size_t bytes = src->len * width; + uint8_t *target = dst->data + (size_t)t * width; + if (dst->elem == src->elem) { + /* The same element kind copies the source's bytes, NaN payloads + * included. A source range that overlaps the target range (views of + * one buffer, where no snapshot was taken) is copied out first. */ + uintptr_t to = (uintptr_t)target, from = (uintptr_t)src->data; + bool overlap = to <= from ? from - to < bytes : to - from < bytes; + if (!overlap) { + scr_bytes_raw_out(dst, target, src->data, bytes); + return; + } + uint8_t *copy = scr_bytes_scratch(bytes); + scr_bytes_raw_in(src, copy, src->data, bytes); + scr_bytes_raw_out(dst, target, copy, bytes); + free(copy); + return; + } + /* Convert a private copy of the source, then publish the target range. */ uint8_t *scratch = scr_bytes_scratch(bytes); scr_bytes_convert_span(scratch, dst->elem, src->data, src->elem, src->len); - scr_bytes_raw_out(dst, dst->data + (size_t)t * width, scratch, bytes); + scr_bytes_raw_out(dst, target, scratch, bytes); free(scratch); return; } diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 7c0a935cc..c7ea68988 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -369,6 +369,12 @@ ScrDyn *scr_text_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*relea void *scr_text_codec_from_dyn(const ScrDyn *d, bool decoder, const struct ScrDynPath *path, const char *want); bool scr_text_codec_is(const ScrDyn *d, bool decoder); +/* StringDecoder records as checked-dynamic handles (the same unit): the + * byte offsets of the encoding (string) and pending (f64) fields. */ +ScrDyn *scr_string_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*release)(void *), + size_t encoding, size_t pending); +void *scr_string_decoder_from_dyn(const ScrDyn *d, const struct ScrDynPath *path, + const char *want); bool scr_buffer_is_ascii(const ScrDyn *input); bool scr_buffer_is_utf8(const ScrDyn *input); ScrBytes *scr_buffer_transcode(const ScrDyn *source, const ScrDyn *from, const ScrDyn *to); diff --git a/packages/runtime/src/scr_bytes.test.c b/packages/runtime/src/scr_bytes.test.c index 58f52fa63..8b2ff3cc1 100644 --- a/packages/runtime/src/scr_bytes.test.c +++ b/packages/runtime/src/scr_bytes.test.c @@ -37,6 +37,52 @@ static void external_set(void) { } } +/* Same-kind sets into and out of shared storage copy raw bytes, NaN + * payloads included, also between overlapping views of one shared buffer. */ +static void shared_set(void) { + enum { COUNT = 37 }; + for (ScrBytesElem elem = SCR_BYTES_U8; elem <= SCR_BYTES_F16; elem++) { + size_t width = scr_bytes_elem_size(elem), bytes = COUNT * width; + uint8_t pattern[COUNT * 8], expected[COUNT * 8]; + for (size_t i = 0; i < bytes; i++) pattern[i] = (uint8_t)(i * 37 + 0x7f); + /* Signaling NaNs, which a conversion through double would quiet. */ + const uint16_t nan16 = 0x7d01; + const uint32_t nan32 = 0x7fa00001; + const uint64_t nan64 = UINT64_C(0x7ff4000000000001); + for (size_t i = 1; i < COUNT; i += 6) { + if (elem == SCR_BYTES_F16) memcpy(pattern + i * width, &nan16, width); + if (elem == SCR_BYTES_F32) memcpy(pattern + i * width, &nan32, width); + if (elem == SCR_BYTES_F64) memcpy(pattern + i * width, &nan64, width); + } + for (int direction = 0; direction < 2; direction++) { + ScrBytes *shared = scr_bytes_new(elem, COUNT); + scr_bytes_make_shared(shared); + memcpy(shared->data, pattern, bytes); + ScrBytes *src = direction ? scr_bytes_subarray(shared, 3, COUNT) : scr_bytes_subarray(shared, 0, COUNT - 3); + double offset = direction ? 0 : 3; + memcpy(expected, pattern, bytes); + memmove(expected + (size_t)offset * width, pattern + (direction ? 3 : 0) * width, (COUNT - 3) * width); + scr_bytes_set_from(shared, src, offset); + assert(!scr_exc_pending()); + assert(memcmp(shared->data, expected, bytes) == 0); + scr_bytes_release(src); + + ScrBytes *local = scr_bytes_new(elem, COUNT); + memcpy(local->data, pattern, bytes); + ScrBytes *head = scr_bytes_subarray(local, 0, COUNT - 1); + scr_bytes_set_from(shared, head, 1); + memcpy(expected + width, pattern, bytes - width); + assert(memcmp(shared->data, expected, bytes) == 0); + memset(local->data, 0, bytes); + scr_bytes_set_from(local, shared, 0); + assert(memcmp(local->data, expected, bytes) == 0); + scr_bytes_release(head); + scr_bytes_release(local); + scr_bytes_release(shared); + } + } +} + static void copied_storage(void) { /* Same-kind copies preserve NaN payloads, signed zero and ownership. */ const uint64_t bits[] = {UINT64_C(0x7ff8000000000042), UINT64_C(0x8000000000000000), @@ -79,6 +125,7 @@ static void filled_storage(void) { void scr_bytes_test_bulk_storage(void) { long before = scr_bytes_live_count(); external_set(); + shared_set(); copied_storage(); filled_storage(); assert(scr_bytes_live_count() == before); diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index b30e8f1aa..d4f9ca395 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -6669,6 +6669,9 @@ double scr_process_fork_target(double target_count) { return target; } +/* The child was created with an IPC channel; it keeps it until it is freed. */ +bool scr_child_ipc_present(ScrChild *c) { return c->ipc != NULL; } + bool scr_child_ipc_connected(ScrChild *c) { return c->ipc != NULL && c->ipc->connected; } diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index a5853ad59..8b0d4d3f1 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -176,6 +176,7 @@ void scr_child_release_v(void *p); void scr_child_on_exit(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildExitFn fn); void scr_child_on_close(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildExitFn fn); void scr_child_on_error(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildErrFn fn); +bool scr_child_ipc_present(ScrChild *c); bool scr_child_ipc_connected(ScrChild *c); bool scr_child_ipc_send(ScrChild *c, ScrStr *json); bool scr_child_ipc_send_cb(ScrChild *c, ScrStr *json, diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 0c91eaaff..52ec29fee 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -23,8 +23,8 @@ * Ownership: the checked-dynamic tree owns its children (strings, items, entry keys+values); * releasing the root releases the tree recursively. scr_json_parse borrows * the text and returns +1 — or THROWS a catchable string shaped like Node's - * V8 messages ("Unexpected end of JSON input"; approximate fidelity, see - * SEMANTICS.md) and returns NULL. scr_dyn_obj_get returns a BORROWED + * V8 messages ("Unexpected end of JSON input", "... in JSON at position + * (line column )") and returns NULL. scr_dyn_obj_get returns a BORROWED * member (the compiler's builders retain what they extract themselves). */ @@ -200,6 +200,7 @@ typedef enum { X(SHARED_ARRAY_BUFFER, 37) \ X(SIGN, 67) /* node:crypto Sign (scr_pki.c) */ \ X(STDIO, 27) /* stable process stdin/stdout/stderr values */ \ + X(STRING_DECODER, 78) /* compiled StringDecoder record (scr_text_codec_dyn.c) */ \ X(TEXT_DECODER, 76) /* compiled TextDecoder record (scr_text_codec_dyn.c) */ \ X(TEXT_ENCODER, 77) /* compiled TextEncoder record (scr_text_codec_dyn.c) */ \ X(URL, 28) /* native WHATWG URL */ \ @@ -1197,6 +1198,10 @@ void scr_weak_dispose(void *object); double scr_dyn_arr_len(const ScrDyn *d); ScrDyn *scr_dyn_arr_at(const ScrDyn *d, double i); ScrDyn *scr_dyn_arr_named_get(const ScrDyn *d, const ScrStr *key); +/* The member `key` of an array found before Array.prototype: an own named + * property, or one on its custom prototype chain (Object.setPrototypeOf). + * NULL when the chain reaches Array.prototype without it. +1. */ +ScrDyn *scr_dyn_arr_custom_member(const ScrDyn *d, const char *key, size_t key_len); void scr_dyn_obj_set(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value); void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value); ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor); @@ -1399,6 +1404,9 @@ ScrDyn *scr_dyn_invoke_prepared(ScrDyn *recv, ScrDyn *callee, const char *method * *mid_slot, the site's -1-initialized slot, caches the name's method id * after the first call, so later calls skip the name lookup. */ ScrDyn *scr_dyn_invoke_prepared_id(ScrDyn *recv, ScrDyn *callee, const char *method, int32_t *mid_slot, ScrDyn *const *args, size_t argc, const char *what); +/* scr_dyn_invoke_prepared_id for a call whose result is discarded: a plain + * array's push appends without boxing its new length. */ +void scr_dyn_invoke_discard_id(ScrDyn *recv, ScrDyn *callee, const char *method, int32_t *mid_slot, ScrDyn *const *args, size_t argc, const char *what); /* scr_dyn_invoke_prepared_id for Buffer.prototype method names * (scr_bytes_dyn.c), one entry per method family: a Buffer receiver runs * the method, any other takes the ordinary dispatch. */ diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index ebbfa8411..4901fa694 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -775,6 +775,8 @@ ScrDyn *scr_dyn_prepare_method(ScrDyn *recv, const char *method) { return scr_dyn_invoke(recv, method, NULL, 0, method); if (recv->kind == SCR_DYN_OBJ) return scr_dyn_obj_read(recv, method, strlen(method)); + if (recv->kind == SCR_DYN_ARR && (recv->v.arr.properties || recv->prototype)) + return scr_dyn_arr_custom_member(recv, method, strlen(method)); if (recv->kind == SCR_DYN_PROXY) { ScrStr *key = scr_str_new(method, strlen(method)); ScrDyn *result = scr_dyn_proxy_get(recv, key); @@ -799,20 +801,117 @@ ScrDyn *scr_dyn_invoke(ScrDyn *recv, const char *method, return scr_dyn_invoke_impl(recv, method, dyn_method_of(method), args, argc, what, NULL); } -ScrDyn *scr_dyn_invoke_prepared_id(ScrDyn *recv, ScrDyn *callee, const char *method, - int32_t *mid_slot, ScrDyn *const *args, size_t argc, - const char *what) { - if (callee) return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); - /* The slot is the call site's, shared by every thread: each writes the - * same id. */ +/* A dense array without holes, named properties or a prototype of its + * own, whose length and elements are writable: its common methods below + * run without the dispatch ladder, which answers every other receiver and + * method as before. */ +static bool dyn_arr_plain(const ScrDyn *recv) { + return recv->kind == SCR_DYN_ARR && !recv->v.arr.presence && !recv->v.arr.properties && + !recv->prototype && !recv->null_proto && !recv->non_extensible && + !recv->v.arr.sealed && !recv->v.arr.frozen && !scr_dyn_is_array_prototype(recv); +} + +/* scr_dyn_strict_eq, with the number and string cases inline: an element + * search compares every element. */ +static inline bool dyn_elem_strict_eq(const ScrDyn *a, const ScrDyn *b) { + if (a->kind == SCR_DYN_STR && b->kind == SCR_DYN_STR) { + const ScrStr *x = a->v.str, *y = b->v.str; + return x == y || (x->len == y->len && memcmp(x->data, y->data, x->len) == 0); + } + if (a->kind == SCR_DYN_NUM && b->kind == SCR_DYN_NUM) return a->v.num == b->v.num; + return scr_dyn_strict_eq(a, b); +} + +/* push, pop, shift, at, includes and indexOf of a plain array, with the + * ladder's results. An argument that would run user code (a non-number + * index, a fromIndex) leaves the call to the ladder. True when answered. */ +static bool dyn_arr_fast(ScrDyn *recv, int mid, ScrDyn *const *args, size_t argc, + ScrDyn **result) { + size_t len = recv->v.arr.len; + switch (mid) { + case DYN_M_push: + for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(recv, scr_dyn_retain(args[i])); + *result = scr_dyn_new_num((double)recv->v.arr.len); + return true; + case DYN_M_pop: + if (len == 0) { + *result = scr_dyn_retain(scr_dyn_undefined()); + return true; + } + recv->v.arr.len = len - 1; + *result = recv->v.arr.items[len - 1]; /* ownership moves out */ + return true; + case DYN_M_shift: + if (len == 0) { + *result = scr_dyn_retain(scr_dyn_undefined()); + return true; + } + *result = recv->v.arr.items[0]; /* ownership moves out */ + memmove(recv->v.arr.items, recv->v.arr.items + 1, (len - 1) * sizeof(ScrDyn *)); + recv->v.arr.len = len - 1; + return true; + case DYN_M_at: { + if (argc == 0 || args[0]->kind != SCR_DYN_NUM) return false; + double index = args[0]->v.num; + index = isnan(index) ? 0 : trunc(index); + if (index < 0) index += (double)len; + *result = index < 0 || index >= (double)len ? scr_dyn_retain(scr_dyn_undefined()) + : scr_dyn_retain(recv->v.arr.items[(size_t)index]); + return true; + } + case DYN_M_includes: + case DYN_M_indexOf: { + if (argc > 1) return false; + ScrDyn *needle = argc > 0 ? args[0] : scr_dyn_undefined(); + bool nan = mid == DYN_M_includes && needle->kind == SCR_DYN_NUM && needle->v.num != needle->v.num; + for (size_t i = 0; i < len; i++) { + const ScrDyn *item = recv->v.arr.items[i]; + if (nan ? item->kind == SCR_DYN_NUM && item->v.num != item->v.num + : dyn_elem_strict_eq(item, needle)) { + *result = mid == DYN_M_includes ? scr_dyn_new_bool(true) : scr_dyn_new_num((double)i); + return true; + } + } + *result = mid == DYN_M_includes ? scr_dyn_new_bool(false) : scr_dyn_new_num(-1); + return true; + } + default: + return false; + } +} + +/* The call site's method id. The slot is the site's, shared by every + * thread: each writes the same id. */ +static int32_t dyn_site_method(const char *method, int32_t *mid_slot) { int32_t mid = __atomic_load_n(mid_slot, __ATOMIC_RELAXED); if (mid < 0) { mid = dyn_method_of(method); __atomic_store_n(mid_slot, mid, __ATOMIC_RELAXED); } + return mid; +} + +ScrDyn *scr_dyn_invoke_prepared_id(ScrDyn *recv, ScrDyn *callee, const char *method, + int32_t *mid_slot, ScrDyn *const *args, size_t argc, + const char *what) { + if (callee) return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); + int32_t mid = dyn_site_method(method, mid_slot); + ScrDyn *result; + if (dyn_arr_plain(recv) && dyn_arr_fast(recv, mid, args, argc, &result)) return result; return scr_dyn_invoke_impl(recv, method, mid, args, argc, what, NULL); } +void scr_dyn_invoke_discard_id(ScrDyn *recv, ScrDyn *callee, const char *method, + int32_t *mid_slot, ScrDyn *const *args, size_t argc, + const char *what) { + /* A plain array's push whose new length nobody reads. */ + if (!callee && dyn_arr_plain(recv) && dyn_site_method(method, mid_slot) == DYN_M_push) { + for (size_t i = 0; i < argc; i++) scr_dyn_arr_push(recv, scr_dyn_retain(args[i])); + return; + } + scr_dyn_release(scr_dyn_invoke_prepared_id(recv, callee, method, mid_slot, args, argc, what)); +} + static ScrDyn *scr_dyn_invoke_impl( ScrDyn *recv, const char *method, int mid, ScrDyn *const *args, size_t argc, const char *what, ScrDyn *callback_recv) { @@ -1125,6 +1224,14 @@ static ScrDyn *scr_dyn_invoke_impl( if (frozen_op >= 0 && scr_array_frozen_error(frozen_op, len, (double)splice_start, frozen_op == 10 ? (double)splice_removed : items, items)) return NULL; + if ((mid == DYN_M_push) && grows && recv->non_extensible) { + scr_array_not_extensible_error((double)len); + return NULL; + } + if ((mid == DYN_M_pop) && shrinks && recv->v.arr.sealed) { + scr_array_undeletable_error((double)(len - 1)); + return NULL; + } if ((grows && recv->non_extensible) || (shrinks && recv->v.arr.sealed && !(mid == DYN_M_shift))) { static const char message[] = "Cannot modify a sealed or frozen array"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); @@ -1154,8 +1261,7 @@ static ScrDyn *scr_dyn_invoke_impl( scr_dyn_release(recv->v.arr.items[i - 1]); recv->v.arr.items[i - 1] = scr_dyn_retain(recv->v.arr.items[i]); } - static const char message[] = "Cannot delete non-configurable property"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_array_undeletable_error((double)(len - 1)); return NULL; } ScrDyn *first = recv->v.arr.items[0]; diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index e99f84243..63dd4a3ca 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -123,6 +123,11 @@ static inline ScrExcCell *scr_exc_swap_cell_inline(ScrExcCell *cell) { _Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR_EXC_NONE == 0, "the LLVM emitter's inline pending check loads an i32 kind at offset 0"); +/* Unwinds the native stack to the nearest landing pad of emitted code with + * the exception pending in the active cell (the unwinding ABI of emitted + * functions, unwind-abi.ts). Linux (glibc and musl) only; it never + * returns. */ +SCR_COLD _Noreturn void scr_unwind_raise(void); SCR_COLD void scr_throw_f64(double v); SCR_COLD void scr_throw_bool(bool v); SCR_COLD void scr_throw_str(ScrStr *v); /* takes ownership */ diff --git a/packages/runtime/src/scr_js.h b/packages/runtime/src/scr_js.h index 6fa20fdb8..1d874b31e 100644 --- a/packages/runtime/src/scr_js.h +++ b/packages/runtime/src/scr_js.h @@ -195,10 +195,13 @@ bool scr_js_truthy(ScrJsVal value); * index and which keys an object lacks. * * Each property site of compiled dynamic-tier code owns one zeroed, - * thread-local ScrJsIc. The code at the site is fixed: it tests entry 0 - * inline and calls a probe for everything else. Probes never run user - * code: they answer from plain data properties or return a miss (NULL / - * false) with nothing done, and the site then runs its generic path. + * thread-local ScrJsIc with static storage: the reset that frees a + * thread's shapes (at thread exit, and when a library session ends) clears + * every record that holds entries. The code at the site is fixed: it tests + * entry 0 inline and calls a probe for everything else. Probes never run + * user code: they answer from plain data properties or return a miss + * (NULL / false) with nothing done, and the site then runs its generic + * path. * * load entry: shape = receiver shape; target = NULL for an own property * (slot indexes the receiver's entries), else the shape of diff --git a/packages/runtime/src/scr_js_object.c b/packages/runtime/src/scr_js_object.c index 63929ab57..0e0ec0908 100644 --- a/packages/runtime/src/scr_js_object.c +++ b/packages/runtime/src/scr_js_object.c @@ -18,6 +18,7 @@ #include "scr_key.h" #include +#include /* The compiler's inline cache tests read these in place (64-bit targets; * backend/llvm/js-ic.ts). */ @@ -37,10 +38,10 @@ _Static_assert(sizeof(ScrJsIcEntry) == 40 && sizeof(ScrJsIc) == 168, "cache layo * take the generic path and its hash index. */ #define SCR_JS_SHAPE_MAX 64 -/* Shapes live until the thread exits, so a thread creates at most this - * many, holding at most this many key bytes. Once either budget is spent, - * objects whose key sequence has no shape yet stay unshaped; shapes that - * exist keep serving. */ +/* Shapes live until the thread exits or a library session ends, so a thread + * creates at most this many, holding at most this many key bytes. Once + * either budget is spent, objects whose key sequence has no shape yet stay + * unshaped; shapes that exist keep serving. */ #define SCR_JS_SHAPE_BUDGET ((size_t)1 << 16) #define SCR_JS_SHAPE_KEY_BYTES_BUDGET ((size_t)4 << 20) @@ -63,12 +64,25 @@ static SCR_TL size_t scr_js_transition_count; static SCR_TL size_t scr_js_shape_key_bytes; static SCR_TL bool scr_js_shapes_registered; +/* The cache records that hold entries. Entries name shapes and prototype + * objects, so the reset that frees the shapes also clears these records: a + * library's next session runs the same sites with the same records. */ +static SCR_TL ScrJsIc **scr_js_filled_ics; +static SCR_TL size_t scr_js_filled_count; +static SCR_TL size_t scr_js_filled_capacity; + /* The shape word of an object whose key sequence has no shape within the * budgets. No cache entry names it, so its accesses take the probes, * which answer with a miss until the key set changes. */ static ScrJsShape scr_js_unshaped; static void scr_js_shapes_reset(void) { + for (size_t i = 0; i < scr_js_filled_count; i++) + memset(scr_js_filled_ics[i], 0, sizeof *scr_js_filled_ics[i]); + free(scr_js_filled_ics); + scr_js_filled_ics = NULL; + scr_js_filled_count = 0; + scr_js_filled_capacity = 0; while (scr_js_all_shapes) { ScrJsShape *shape = scr_js_all_shapes; scr_js_all_shapes = shape->all_next; @@ -192,6 +206,16 @@ static size_t scr_js_shape_find(const ScrJsShape *shape, const char *key, size_t /* Fills a cache entry: `stale` (a matching entry whose hit failed) when * there is one, else a free or replaceable one. */ static void scr_js_ic_add(ScrJsIc *ic, uint32_t stale, ScrJsIcEntry entry) { + if (ic->count == 0) { + if (scr_js_filled_count == scr_js_filled_capacity) { + size_t capacity = scr_js_filled_capacity ? scr_js_filled_capacity * 2 : 64; + ScrJsIc **grown = realloc(scr_js_filled_ics, capacity * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + scr_js_filled_ics = grown; + scr_js_filled_capacity = capacity; + } + scr_js_filled_ics[scr_js_filled_count++] = ic; + } uint32_t index; if (stale < ic->count) { index = stale; diff --git a/packages/runtime/src/scr_js_object.test.c b/packages/runtime/src/scr_js_object.test.c index a14aa9ff6..6b6934dea 100644 --- a/packages/runtime/src/scr_js_object.test.c +++ b/packages/runtime/src/scr_js_object.test.c @@ -24,11 +24,17 @@ static ScrDyn *object_with(const ScrStr *key, double n) { return object; } +/* Cache records have static storage, as the compiled sites' do: the reset + * at exit clears every record that holds entries. Each check starts from + * an empty record. */ +static ScrJsIc load_ic; +static ScrJsIc store_ic; + /* Whether a load probe answers `object.key` and returns `n`. */ static bool probe_reads(ScrDyn *object, const ScrStr *key, double n) { - ScrJsIc ic; - memset(&ic, 0, sizeof ic); - ScrDyn *value = scr_js_load_probe(object, key, &ic); + ScrJsIc *ic = &load_ic; + memset(ic, 0, sizeof *ic); + ScrDyn *value = scr_js_load_probe(object, key, ic); if (!value) return false; assert(value->kind == SCR_DYN_NUM && value->v.num == n); scr_dyn_release(value); @@ -66,14 +72,13 @@ static void budget_bounds_shapes(size_t key_len, size_t budget) { /* A store probe that would add a new shape leaves the add to the generic * store; one that replaces through an existing shape still runs. */ - ScrJsIc ic; - memset(&ic, 0, sizeof ic); + memset(&store_ic, 0, sizeof store_ic); ScrStr *added = scr_str_new("added", 5); ScrDyn *value = scr_dyn_new_num(8); - assert(!scr_js_store_probe(object, added, value, &ic)); + assert(!scr_js_store_probe(object, added, value, &store_ic)); assert(object->v.obj.len == 1); - memset(&ic, 0, sizeof ic); - assert(scr_js_store_probe(object, first, value, &ic)); + memset(&store_ic, 0, sizeof store_ic); + assert(scr_js_store_probe(object, first, value, &store_ic)); assert(probe_reads(object, first, 8)); scr_dyn_release(value); scr_str_release(added); @@ -90,10 +95,9 @@ static void immortal_objects_stay_unshaped(void) { object->rc = SIZE_MAX; assert(!probe_reads(object, key, 3)); assert(object->v.obj.shape == NULL); - ScrJsIc ic; - memset(&ic, 0, sizeof ic); + memset(&store_ic, 0, sizeof store_ic); ScrDyn *value = scr_dyn_new_num(4); - assert(!scr_js_store_probe(object, key, value, &ic)); + assert(!scr_js_store_probe(object, key, value, &store_ic)); assert(object->v.obj.shape == NULL); scr_dyn_release(value); scr_str_release(key); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index eb03ab6cc..82667308d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -9,10 +9,10 @@ * arrays, objects (later duplicate keys win, like JS), and the * four JSON whitespace characters. Syntax errors THROW catchable * SyntaxError instances (the depth cap a RangeError, like V8's) whose - * messages are shaped like Node's V8 texts ("Unexpected end of JSON - * input", "Unexpected token 'x', \"...\" is not valid JSON") — - * APPROXIMATE message fidelity by design (SEMANTICS.md; e.name is exact, - * e.message is ours), pinned by the runtime C tests. + * messages are Node's V8 texts ("Unexpected end of JSON input", + * "Unexpected token 'x', \"...\" is not valid JSON", "... in JSON at + * position (line column )"), reported where V8 + * reports them. * - scr_dyn_check_fail is the shared failure path of the compiler-emitted * dynCheck builders: a TypeError instance carrying "expected at * , got ", thrown through the exception cell. @@ -1739,6 +1739,21 @@ ScrDyn *scr_dyn_arr_at(const ScrDyn *d, double i) { return result; } +ScrDyn *scr_dyn_arr_custom_member(const ScrDyn *d, const char *key, size_t key_len) { + if (d->v.arr.properties && scr_dyn_obj_get(d->v.arr.properties, key, key_len)) + return scr_dyn_obj_read_receiver(d->v.arr.properties, key, key_len, d); + for (const ScrDyn *link = d->prototype; link && link != scr_builtin_array_prototype; + link = link->prototype) { + const ScrDyn *table = link->kind == SCR_DYN_OBJ ? link + : link->kind == SCR_DYN_ARR ? link->v.arr.properties + : NULL; + if (table && scr_dyn_obj_get(table, key, key_len)) + return scr_dyn_obj_read_receiver(table, key, key_len, d); + if (link->kind != SCR_DYN_OBJ && link->kind != SCR_DYN_ARR) break; + } + return NULL; +} + ScrDyn *scr_dyn_arr_named_get(const ScrDyn *d, const ScrStr *key) { if (d->v.arr.properties && scr_dyn_obj_get(d->v.arr.properties, key->data, key->len)) return scr_dyn_obj_read_receiver(d->v.arr.properties, key->data, key->len, d); @@ -2204,6 +2219,40 @@ static bool scr_dyn_prototype_valid(const ScrDyn *prototype) { } /* A valid receiver or prototype whose kind this operation does not handle. */ +/* Compiled class prototypes. Compiled code binds a class's methods, its + * base chain and its instanceof answers when it compiles, so the + * prototype's own [[Prototype]] is fixed: scr_dyn_set_prototype refuses to + * change it. Each module-level class prototype registers when it is + * created (scr_dyn_name_class_prototype) and stays alive until its thread + * exits, as the class does. */ +static SCR_TL ScrDyn **scr_dyn_fixed_prototypes; +static SCR_TL size_t scr_dyn_fixed_count, scr_dyn_fixed_cap; + +static void scr_dyn_fixed_prototypes_cleanup(void) { + for (size_t i = 0; i < scr_dyn_fixed_count; i++) scr_dyn_release(scr_dyn_fixed_prototypes[i]); + free(scr_dyn_fixed_prototypes); + scr_dyn_fixed_prototypes = NULL; + scr_dyn_fixed_count = scr_dyn_fixed_cap = 0; +} + +static void scr_dyn_fix_prototype(ScrDyn *prototype) { + if (scr_dyn_fixed_count == scr_dyn_fixed_cap) { + if (scr_dyn_fixed_cap == 0) scr_atexit(scr_dyn_fixed_prototypes_cleanup); + size_t cap = scr_dyn_fixed_cap ? scr_dyn_fixed_cap * 2 : 8; + ScrDyn **grown = realloc(scr_dyn_fixed_prototypes, cap * sizeof *grown); + if (!grown) scr_json_oom(); + scr_dyn_fixed_prototypes = grown; + scr_dyn_fixed_cap = cap; + } + scr_dyn_fixed_prototypes[scr_dyn_fixed_count++] = scr_dyn_retain(prototype); +} + +static bool scr_dyn_prototype_fixed(const ScrDyn *object) { + for (size_t i = 0; i < scr_dyn_fixed_count; i++) + if (scr_dyn_fixed_prototypes[i] == object) return true; + return false; +} + static void scr_dyn_prototype_unsupported(const char *operation) { ScrJsonBuf b; scr_jb_init(&b); @@ -2323,6 +2372,11 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { return NULL; } if (!scr_dyn_prototype_valid(prototype)) return NULL; + if (object->kind == SCR_DYN_OBJ && scr_dyn_fixed_count && scr_dyn_prototype_fixed(object) && + !(prototype->kind == SCR_DYN_NULL ? object->null_proto : prototype == object->prototype)) { + scr_dyn_prototype_unsupported("Object.setPrototypeOf"); + return NULL; + } bool typed = scr_dyn_typed_ref_prototype(prototype); if (scr_exc_pending()) return NULL; if ((object->kind != SCR_DYN_OBJ && object->kind != SCR_DYN_ARR) || @@ -4665,6 +4719,115 @@ int scr_dyn_has_instance_hook(const ScrDyn *value, const ScrDyn *callee) { return truthy ? 1 : 0; } +/* `value instanceof Object`: whether Object.prototype is on the value's + * prototype chain. Primitives answer false, and so does an object whose + * chain ends in null first (Object.create(null), a null-prototype record). + * Objects without a chain the runtime walks (class instances, native + * handles, errors, promises) inherit Object.prototype. False with the + * exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_object(const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: + return false; + default: + break; + } + /* Object.prototype is on the chain from the value's own prototype on: + * Object.prototype itself has a null prototype, so it is not an Object. */ + ScrDyn *current = scr_dyn_retain((ScrDyn *)value); + for (bool first = true;; first = false) { + if (!first && current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return true; + } + if (current == scr_builtin_object_prototype) { + scr_dyn_release(current); + return false; + } + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + /* A function's prototype is Function.prototype, whose own is + * Object.prototype, unless the program set it. */ + if (scr_closure_identity(current->v.fn.clo)->props) { + ScrDyn *table = scr_dyn_fn_properties(current); + next = table->prototype ? scr_dyn_retain(table->prototype) + : table->null_proto ? scr_dyn_new_null() : NULL; + scr_dyn_release(table); + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR || + (current->kind == SCR_DYN_TYPED_REF && + strncmp(current->v.typed_ref.type_key, "object:", 7) != 0)) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + if (!next) return true; + if (next->kind == SCR_DYN_NULL) { + scr_dyn_release(next); + return false; + } + current = next; + } +} + +/* `value instanceof Function`: whether a function is on the value's + * prototype chain. A function's own prototype is Function.prototype unless + * the program set it, so the walk ends true at the first function that + * kept its prototype; it follows set prototypes through functions and + * objects alike (`Object.create(function () {})` is a Function instance), + * and ends false at null or at a value without a chain the runtime walks. + * False with the exception pending when a prototype read throws. */ +static bool scr_dyn_instanceof_function(const ScrDyn *value) { + ScrDyn *current = NULL; + if (value->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(value->v.fn.clo)->props) return true; + ScrDyn *table = scr_dyn_fn_properties((ScrDyn *)value); + current = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + bool unset = !table->prototype && !table->null_proto; + scr_dyn_release(table); + if (unset) return true; + } else if (value->kind == SCR_DYN_OBJ || value->kind == SCR_DYN_ARR) { + current = scr_dyn_get_prototype((ScrDyn *)value); + if (!current) return false; /* the read threw: pending */ + } + while (current && current->kind != SCR_DYN_NULL) { + ScrDyn *next = NULL; + if (current->kind == SCR_DYN_FUNC) { + if (!scr_closure_identity(current->v.fn.clo)->props) { + scr_dyn_release(current); + return true; + } + ScrDyn *table = scr_dyn_fn_properties(current); + bool unset = !table->prototype && !table->null_proto; + next = table->prototype ? scr_dyn_retain(table->prototype) : NULL; + scr_dyn_release(table); + if (unset) { + scr_dyn_release(current); + return true; + } + } else if (current->kind == SCR_DYN_OBJ || current->kind == SCR_DYN_ARR) { + next = scr_dyn_get_prototype(current); + if (!next) { + scr_dyn_release(current); + return false; /* the read threw: pending */ + } + } + scr_dyn_release(current); + current = next; + } + if (current) scr_dyn_release(current); + return false; +} + bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const ScrStr *refusal) { if (!callee || callee->kind != SCR_DYN_FUNC) { static const char message[] = "Right-hand side of 'instanceof' is not callable"; @@ -4673,6 +4836,13 @@ bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const Sc } int hooked = scr_dyn_has_instance_hook(value, callee); if (hooked >= 0) return hooked == 1; + if (callee->v.fn.sig && callee->v.fn.name) { + if (strcmp(callee->v.fn.sig, "native:constructor") == 0 && strcmp(callee->v.fn.name, "Object") == 0) + return scr_dyn_instanceof_object(value); + if (strcmp(callee->v.fn.sig, "native:function-constructor") == 0 && + strcmp(callee->v.fn.name, "Function") == 0) + return scr_dyn_instanceof_function(value); + } /* An error constructor held as a value (`inst instanceof params.Parent` * with Parent === Error): the error kind's interval, and for Error itself * objects inheriting Error.prototype. */ @@ -6411,7 +6581,9 @@ static void scr_dyn_name_prototype_chain(const ScrDyn *object) { } void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) { - if (prototype->kind != SCR_DYN_OBJ || prototype->v.obj.class_name || name->len == 0) return; + if (prototype->kind != SCR_DYN_OBJ) return; + if (!scr_dyn_prototype_fixed(prototype)) scr_dyn_fix_prototype(prototype); + if (prototype->v.obj.class_name || name->len == 0) return; scr_dyn_stamp_prototype_name(prototype, name->data, name->len); } @@ -11100,26 +11272,142 @@ static void scr_json_throw(const ScrJsonP *p, const char *msg) { scr_throw_error_msg(SCR_ERR_SYNTAX, msg, strlen(msg)); } +/* V8's location of a JSON error at byte `pos`: the UTF-16 offset (the + * text is WTF-8; a four-byte sequence is two units), and the 1-based line + * and column, where \r, \n and \r\n end a line. */ +static void scr_json_location(const ScrJsonP *p, size_t pos, size_t *unit, size_t *line, + size_t *column) { + const unsigned char *s = (const unsigned char *)p->s; + size_t units = 0, ln = 1, line_start = 0; + for (size_t i = 0; i < pos; i++) { + unsigned char c = s[i]; + if ((c & 0xC0) == 0x80) continue; + if (c == '\r' && i + 1 < pos && s[i + 1] == '\n') { + i++; + units++; + c = '\n'; + } + units += c >= 0xF0 ? 2 : 1; + if (c == '\r' || c == '\n') { + ln++; + line_start = units; + } + } + *unit = units; + *line = ln; + *column = 1 + units - line_start; +} + /* Errors stay out of line so the scanners that report them keep their - * registers for the common path. */ -static __attribute__((noinline, cold)) void scr_json_throw_pos(const ScrJsonP *p, const char *what, - size_t pos) { + * registers for the common path. V8's positional messages: + * " in JSON at position (line column )". */ +static __attribute__((noinline, cold)) void scr_json_throw_located(const ScrJsonP *p, + const char *what, + const char *where, size_t pos) { if (p->quiet) return; - char buf[128]; - int n = snprintf(buf, sizeof buf, "%s in JSON at position %zu", what, pos); + size_t unit, line, column; + scr_json_location(p, pos, &unit, &line, &column); + char buf[192]; + int n = snprintf(buf, sizeof buf, "%s%s at position %zu (line %zu column %zu)", what, where, unit, + line, column); scr_throw_error_msg(SCR_ERR_SYNTAX, buf, (size_t)n); } -/* V8-flavored bad-token message with a short snippet of the input around - * the offending character. Approximate fidelity (documented). */ -static void scr_json_throw_token(const ScrJsonP *p) { +static __attribute__((noinline, cold)) void scr_json_throw_pos(const ScrJsonP *p, const char *what, + size_t pos) { + scr_json_throw_located(p, what, " in JSON", pos); +} + +/* Appends the text's UTF-16 units [from, to) as WTF-8: a surrogate pair the + * range cuts contributes its half as a lone surrogate, as V8's substring. */ +static size_t scr_json_unit_slice(const ScrJsonP *p, size_t from, size_t to, char *out) { + const unsigned char *s = (const unsigned char *)p->s; + size_t n = 0, unit = 0, i = 0; + while (i < p->len && unit < to) { + unsigned char c = s[i]; + size_t width = c < 0x80 ? 1 : c < 0xE0 ? 2 : c < 0xF0 ? 3 : 4; + if (i + width > p->len) width = p->len - i; + if (width == 4) { + uint32_t cp = ((uint32_t)(c & 0x07) << 18) | ((uint32_t)(s[i + 1] & 0x3F) << 12) | + ((uint32_t)(s[i + 2] & 0x3F) << 6) | (uint32_t)(s[i + 3] & 0x3F); + bool high = unit >= from && unit < to, low = unit + 1 >= from && unit + 1 < to; + if (high && low) { + memcpy(out + n, s + i, 4); + n += 4; + } else if (high) { + n += scr_wtf8_put(out + n, 0xD800 + ((cp - 0x10000) >> 10)); + } else if (low) { + n += scr_wtf8_put(out + n, 0xDC00 + ((cp - 0x10000) & 0x3FF)); + } + unit += 2; + } else { + if (unit >= from) { + memcpy(out + n, s + i, width); + n += width; + } + unit++; + } + i += width; + } + return n; +} + +/* V8's report of an unexpected token at byte `pos`: the end of the input, + * a string or a number, or any other character, named with the source (a + * short one whole, a longer one as up to ten units on each side), except + * that the sources "NaN", "Infinity", "undefined" and "[object Object]" + * name only themselves. */ +static __attribute__((noinline, cold)) void scr_json_throw_unexpected(const ScrJsonP *p, + size_t pos) { if (p->quiet) return; - size_t start = p->pos > 8 ? p->pos - 8 : 0; - size_t take = p->len - start < 16 ? p->len - start : 16; - char buf[192]; - int n = snprintf(buf, sizeof buf, "Unexpected token '%c', %s\"%.*s\"%s is not valid JSON", - p->s[p->pos], start > 0 ? "..." : "", (int)take, p->s + start, - start + take < p->len ? "..." : ""); + if (pos >= p->len) { + scr_json_throw(p, "Unexpected end of JSON input"); + return; + } + char c = p->s[pos]; + if (c == '"') { + scr_json_throw_pos(p, "Unexpected string", pos); + return; + } + if (c == '-' || (c >= '0' && c <= '9')) { + scr_json_throw_pos(p, "Unexpected number", pos); + return; + } + static const char *const special[] = {"NaN", "Infinity", "undefined", "[object Object]"}; + char buf[256]; + int n; + for (size_t k = 0; k < sizeof special / sizeof special[0]; k++) { + if (p->len == strlen(special[k]) && memcmp(p->s, special[k], p->len) == 0) { + n = snprintf(buf, sizeof buf, "\"%s\" is not valid JSON", special[k]); + scr_throw_error_msg(SCR_ERR_SYNTAX, buf, (size_t)n); + return; + } + } + size_t unit, line, column, total; + scr_json_location(p, pos, &unit, &line, &column); + scr_json_location(p, p->len, &total, &line, &column); + char token[8]; + size_t token_len = scr_json_unit_slice(p, unit, unit + 1, token); + enum { CONTEXT = 10 }; + size_t from = 0, to = total; + const char *before = "", *after = ""; + if (total >= 2 * CONTEXT + 1) { + if (unit < CONTEXT) { + to = unit + CONTEXT; + after = "..."; + } else if (unit < total - CONTEXT) { + from = unit - CONTEXT; + to = unit + CONTEXT; + before = after = "..."; + } else { + from = unit - CONTEXT; + before = "..."; + } + } + char source[4 * 2 * CONTEXT + 8]; + size_t source_len = scr_json_unit_slice(p, from, to, source); + n = snprintf(buf, sizeof buf, "Unexpected token '%.*s', %s\"%.*s\"%s is not valid JSON", + (int)token_len, token, before, (int)source_len, source, after); scr_throw_error_msg(SCR_ERR_SYNTAX, buf, (size_t)n); } @@ -11144,12 +11432,13 @@ static void scr_json_put_cp(ScrJsonBuf *b, uint32_t cp) { /* Four hex digits at pos, or -1 (throws). */ static int32_t scr_json_hex4(ScrJsonP *p) { - if (p->len - p->pos < 4) { - scr_json_throw(p, "Unexpected end of JSON input"); - return -1; - } uint32_t v = 0; for (int i = 0; i < 4; i++) { + if (p->pos + (size_t)i >= p->len) { + /* V8 reports the escape at the end of the input. */ + scr_json_throw_pos(p, "Bad Unicode escape", p->len); + return -1; + } char c = p->s[p->pos + (size_t)i]; uint32_t digit; if (c >= '0' && c <= '9') digit = (uint32_t)(c - '0'); @@ -11190,7 +11479,7 @@ static int scr_json_string_span(ScrJsonP *p, const char **span, return -1; } } - scr_json_throw_pos(p, "Unterminated string", p->pos); + scr_json_throw_pos(p, "Unterminated string", p->len); return -1; } @@ -11206,7 +11495,7 @@ static ScrStr *scr_json_string_slow(ScrJsonP *p, size_t prefix) { for (;;) { if (p->pos >= p->len) { scr_jb_dispose(&b); - scr_json_throw_pos(p, "Unterminated string", open); + scr_json_throw_pos(p, "Unterminated string", p->len); return NULL; } unsigned char c = (unsigned char)p->s[p->pos]; @@ -11310,6 +11599,12 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { } if (s[pos] == '0') { pos++; + /* A leading zero followed by a digit: V8's unexpected number there. */ + if (pos < len && s[pos] >= '0' && s[pos] <= '9') { + p->pos = pos; + scr_json_throw_pos(p, "Unexpected number", pos); + return false; + } } else { while (pos < len && s[pos] >= '0' && s[pos] <= '9') { if (ndig < 15) { @@ -11390,7 +11685,10 @@ static bool scr_json_lit(ScrJsonP *p, const char *word, size_t n) { p->pos += n; return true; } - scr_json_throw_token(p); + /* V8 names the first character that differs, or the end of the input. */ + size_t i = 1; + while (p->pos + i < p->len && i < n && p->s[p->pos + i] == word[i]) i++; + scr_json_throw_unexpected(p, p->pos + i); return false; } @@ -11450,7 +11748,7 @@ static ScrDyn *scr_json_array(ScrJsonP *p) { scr_json_ws(p); if (p->pos >= p->len) { scr_dyn_release(arr); - scr_json_throw(p, "Unexpected end of JSON input"); + scr_json_throw_pos(p, "Expected ',' or ']' after array element", p->pos); return NULL; } if (p->s[p->pos] == ',') { @@ -11580,18 +11878,20 @@ static ScrDyn *scr_json_object(ScrJsonP *p) { } uint32_t shape = 0; /* the empty layout */ bool shaped = true; /* obj holds exactly the keys on shape's path */ + bool first = true; for (;;) { scr_json_ws(p); - if (p->pos >= p->len) { + if (p->pos >= p->len || p->s[p->pos] != '"') { + /* V8: the first member may also close the object; after a comma + * only a property name may follow. */ scr_dyn_release(obj); - scr_json_throw(p, "Unexpected end of JSON input"); - return NULL; - } - if (p->s[p->pos] != '"') { - scr_dyn_release(obj); - scr_json_throw_pos(p, "Expected property name or '}'", p->pos); + scr_json_throw_pos(p, + first ? "Expected property name or '}'" + : "Expected double-quoted property name", + p->pos); return NULL; } + first = false; /* Keep the key borrowed until insertion determines whether it already * exists or can reuse a recycled slot. Escaped keys own their decoded * string until the value has been parsed and inserted. */ @@ -11629,8 +11929,7 @@ static ScrDyn *scr_json_object(ScrJsonP *p) { if (p->pos >= p->len || p->s[p->pos] != ':') { scr_str_release(decoded_key); scr_dyn_release(obj); - if (p->pos >= p->len) scr_json_throw(p, "Unexpected end of JSON input"); - else scr_json_throw_pos(p, "Expected ':' after property name", p->pos); + scr_json_throw_pos(p, "Expected ':' after property name", p->pos); return NULL; } p->pos++; /* ':' */ @@ -11657,7 +11956,7 @@ static ScrDyn *scr_json_object(ScrJsonP *p) { scr_json_ws(p); if (p->pos >= p->len) { scr_dyn_release(obj); - scr_json_throw(p, "Unexpected end of JSON input"); + scr_json_throw_pos(p, "Expected ',' or '}' after property value", p->pos); return NULL; } if (p->s[p->pos] == ',') { @@ -11715,7 +12014,7 @@ static ScrDyn *scr_json_value(ScrJsonP *p) { return scr_dyn_new_null(); } if (c == '-' || (c >= '0' && c <= '9')) return scr_json_number(p); - scr_json_throw_token(p); + scr_json_throw_unexpected(p, p->pos); return NULL; } @@ -11735,10 +12034,7 @@ ScrDyn *scr_json_parse(ScrStr *text) { scr_json_ws(&p); if (p.pos < p.len) { scr_dyn_release(d); - char buf[96]; - int n = snprintf(buf, sizeof buf, - "Unexpected non-whitespace character after JSON at position %zu", p.pos); - scr_throw_error_msg(SCR_ERR_SYNTAX, buf, (size_t)n); + scr_json_throw_located(&p, "Unexpected non-whitespace character after JSON", "", p.pos); return NULL; } return d; @@ -16135,26 +16431,176 @@ typedef struct ScrWeakEntry { unsigned kind; ScrDyn *value; ScrWeakMap *owner; - struct ScrWeakEntry *next, *all_next; + /* The owner's entries, and every collection's entries for the same key + * pointer (whatever their kind): both doubly linked, so an entry leaves + * either list in constant time. */ + struct ScrWeakEntry *next, *prev, *key_next, *key_prev; } ScrWeakEntry; struct ScrWeakMap { size_t rc; ScrWeakEntry *entries; bool is_set; }; /* Weak observers borrow key identities. Values are traced strong edges; - * value-to-key cycles require explicit deletion or container disposal. */ -#define SCR_WEAK_BUCKETS 1024 -static SCR_TL ScrWeakEntry *scr_weak_entries[SCR_WEAK_BUCKETS]; -static size_t scr_weak_bucket(void *key) { - uintptr_t bits = (uintptr_t)key; - return ((bits >> 4) ^ (bits >> 14)) & (SCR_WEAK_BUCKETS - 1); + * value-to-key cycles require explicit deletion or container disposal. + * + * Two open-addressing indexes per thread find entries in expected constant + * time: scr_weak_index by (collection, key, kind), for get/set/has/delete, + * and scr_weak_keys by key pointer, the head of that key's entries, for a + * disposal, which ends the key's entries in every collection. Both use + * linear probing with backward-shift deletion, grow at half load, and are + * freed when their last entry goes. */ +static SCR_TL ScrWeakEntry **scr_weak_index; +static SCR_TL size_t scr_weak_index_mask, scr_weak_index_used; +typedef struct { void *key; ScrWeakEntry *head; } ScrWeakKeySlot; +static SCR_TL ScrWeakKeySlot *scr_weak_keys; +static SCR_TL size_t scr_weak_keys_mask, scr_weak_keys_used; + +/* Mixed in 64 bits on every target: uintptr_t is 32 bits on wasm32, where + * these shifts would exceed its width. */ +static inline size_t scr_weak_mix(uint64_t bits) { + bits ^= bits >> 33; + bits *= UINT64_C(0xff51afd7ed558ccd); + bits ^= bits >> 29; + return (size_t)bits; +} +static inline size_t scr_weak_entry_hash(const ScrWeakMap *owner, const void *key, unsigned kind) { + return scr_weak_mix((uint64_t)(uintptr_t)key ^ ((uint64_t)(uintptr_t)owner * UINT64_C(0x9E3779B97F4A7C15)) ^ + ((uint64_t)kind << 56)); +} +static inline size_t scr_weak_key_hash(const void *key) { return scr_weak_mix((uint64_t)(uintptr_t)key); } + +static void scr_weak_index_put(ScrWeakEntry *entry) { + size_t j = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & scr_weak_index_mask; + while (scr_weak_index[j]) j = (j + 1) & scr_weak_index_mask; + scr_weak_index[j] = entry; +} +static void scr_weak_index_reserve(void) { + if (scr_weak_index && (scr_weak_index_used + 1) * 2 <= scr_weak_index_mask + 1) return; + ScrWeakEntry **old = scr_weak_index; + size_t n = old ? scr_weak_index_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_index = calloc(cap, sizeof *scr_weak_index); + if (!scr_weak_index) scr_json_oom(); + scr_weak_index_mask = cap - 1; + for (size_t i = 0; i < n; i++) + if (old[i]) scr_weak_index_put(old[i]); + free(old); +} +static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { + if (!scr_weak_index) return NULL; + for (size_t j = scr_weak_entry_hash(map, ptr, kind) & scr_weak_index_mask;; j = (j + 1) & scr_weak_index_mask) { + ScrWeakEntry *entry = scr_weak_index[j]; + if (!entry) return NULL; + if (entry->owner == map && entry->key == ptr && entry->kind == kind) return entry; + } +} +static void scr_weak_index_delete(ScrWeakEntry *entry) { + size_t mask = scr_weak_index_mask; + size_t i = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & mask; + while (scr_weak_index[i] != entry) i = (i + 1) & mask; + if (--scr_weak_index_used == 0) { + /* Empty: give the table back, so a thread that exits leaves none. */ + free(scr_weak_index); + scr_weak_index = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_index[i] = NULL; + for (;;) { + k = (k + 1) & mask; + ScrWeakEntry *e = scr_weak_index[k]; + if (!e) return; + size_t home = scr_weak_entry_hash(e->owner, e->key, e->kind) & mask; + /* e stays unless its home lies cyclically outside (i, k]. */ + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_index[i] = e; + i = k; + break; + } + } +} + +static ScrWeakKeySlot *scr_weak_key_slot(const void *key) { + if (!scr_weak_keys) return NULL; + for (size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask;; j = (j + 1) & scr_weak_keys_mask) { + if (scr_weak_keys[j].key == key) return &scr_weak_keys[j]; + if (!scr_weak_keys[j].key) return NULL; + } +} +static ScrWeakKeySlot *scr_weak_key_slot_put(void *key) { + if (!scr_weak_keys || (scr_weak_keys_used + 1) * 2 > scr_weak_keys_mask + 1) { + ScrWeakKeySlot *old = scr_weak_keys; + size_t n = old ? scr_weak_keys_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_keys = calloc(cap, sizeof *scr_weak_keys); + if (!scr_weak_keys) scr_json_oom(); + scr_weak_keys_mask = cap - 1; + for (size_t i = 0; i < n; i++) { + if (!old[i].key) continue; + size_t j = scr_weak_key_hash(old[i].key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key) j = (j + 1) & scr_weak_keys_mask; + scr_weak_keys[j] = old[i]; + } + free(old); + } + size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key && scr_weak_keys[j].key != key) j = (j + 1) & scr_weak_keys_mask; + if (!scr_weak_keys[j].key) { + scr_weak_keys[j].key = key; + scr_weak_keys[j].head = NULL; + scr_weak_keys_used++; + } + return &scr_weak_keys[j]; +} +static void scr_weak_key_slot_delete(ScrWeakKeySlot *slot) { + size_t mask = scr_weak_keys_mask; + size_t i = (size_t)(slot - scr_weak_keys); + if (--scr_weak_keys_used == 0) { + free(scr_weak_keys); + scr_weak_keys = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_keys[i].key = NULL; + scr_weak_keys[i].head = NULL; + for (;;) { + k = (k + 1) & mask; + if (!scr_weak_keys[k].key) return; + size_t home = scr_weak_key_hash(scr_weak_keys[k].key) & mask; + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_keys[i] = scr_weak_keys[k]; + i = k; + break; + } + } +} + +/* Indexes a new entry (its key, kind and owner set) under its key. */ +static void scr_weak_link(ScrWeakEntry *entry) { + scr_weak_index_reserve(); + scr_weak_index_put(entry); + scr_weak_index_used++; + ScrWeakKeySlot *slot = scr_weak_key_slot_put(entry->key); + entry->key_prev = NULL; + entry->key_next = slot->head; + if (slot->head) slot->head->key_prev = entry; + slot->head = entry; +} + +/* Takes an entry out of both indexes (it stays in its owner's list). */ +static void scr_weak_unlink(ScrWeakEntry *entry) { + scr_weak_index_delete(entry); + if (entry->key_next) entry->key_next->key_prev = entry->key_prev; + if (entry->key_prev) entry->key_prev->key_next = entry->key_next; + else { + ScrWeakKeySlot *slot = scr_weak_key_slot(entry->key); + if (entry->key_next) slot->head = entry->key_next; + else scr_weak_key_slot_delete(slot); + } } static void scr_weak_remove_impl(ScrWeakEntry *entry, bool collected) { - ScrWeakEntry **slot = &entry->owner->entries; - while (*slot != entry) slot = &(*slot)->next; - *slot = entry->next; - slot = &scr_weak_entries[scr_weak_bucket(entry->key)]; - while (*slot != entry) slot = &(*slot)->all_next; - *slot = entry->all_next; + ScrWeakMap *owner = entry->owner; + if (entry->next) entry->next->prev = entry->prev; + if (entry->prev) entry->prev->next = entry->next; + else owner->entries = entry->next; + scr_weak_unlink(entry); ScrDyn *value = entry->value; free(entry); /* Unlink before release: releasing a value can dispose another key. */ @@ -16178,10 +16624,9 @@ static void scr_weak_map_gcfree(void *ptr) { static void scr_weak_disposed(void *key) { for (;;) { - ScrWeakEntry *entry = scr_weak_entries[scr_weak_bucket(key)]; - while (entry && entry->key != key) entry = entry->all_next; - if (!entry) return; - scr_weak_remove(entry); + ScrWeakKeySlot *slot = scr_weak_key_slot(key); + if (!slot) return; + scr_weak_remove(slot->head); } } @@ -16239,12 +16684,6 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) { } } -static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { - for (ScrWeakEntry *entry = map->entries; entry; entry = entry->next) - if (entry->key == ptr && entry->kind == kind) return entry; - return NULL; -} - static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { void *ptr = NULL; unsigned kind = 0; @@ -16273,10 +16712,9 @@ static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { entry->value = scr_dyn_retain(value); entry->owner = map; entry->next = map->entries; + if (map->entries) map->entries->prev = entry; map->entries = entry; - size_t bucket = scr_weak_bucket(ptr); - entry->all_next = scr_weak_entries[bucket]; - scr_weak_entries[bucket] = entry; + scr_weak_link(entry); return true; } @@ -17513,16 +17951,14 @@ static bool scr_dyn_decl_rekey(ScrDyn *node, void *ptr) { bool moved = false; if (!scr_weak_observing) return false; for (;;) { - ScrWeakEntry **slot = &scr_weak_entries[scr_weak_bucket(node)]; - while (*slot && !((*slot)->key == (void *)node && (*slot)->kind == 0)) slot = &(*slot)->all_next; - ScrWeakEntry *entry = *slot; + ScrWeakKeySlot *slot = scr_weak_key_slot(node); + ScrWeakEntry *entry = slot ? slot->head : NULL; + while (entry && entry->kind != 0) entry = entry->key_next; if (!entry) return moved; - *slot = entry->all_next; + scr_weak_unlink(entry); entry->key = ptr; entry->kind = 32; - ScrWeakEntry **head = &scr_weak_entries[scr_weak_bucket(ptr)]; - entry->all_next = *head; - *head = entry; + scr_weak_link(entry); moved = true; } } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fb3524c81..69c19e1b7 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -11660,6 +11660,56 @@ double scr_date_new_local(double y, double mo, double d, return scr_date_from_local(scr_date_v8_make_day(y, mo, d), scr_date_v8_make_time(h, mi, s, ms)); } +/* Date.prototype's setters over a time value, as V8's builtins-date.cc + * computes them: the fields of `t` (local fields for the local setters) + * with the first `argc` of them replaced, in the order the setter names + * them, by `a`..`d`, through MakeDay/MakeTime/MakeDate, then UTC(t) for + * the local setters and TimeClip. An invalid `t` stays invalid, except + * that setFullYear and setUTCFullYear start from +0: January 1 of the + * epoch year at midnight (local midnight for setFullYear). Returns the new + * time value; the caller stores it into the Date. Never throws. */ +double scr_date_set_fields(double t, double setter_code, double argc_count, + double a, double b, double c, double d) { + int32_t setter = (int32_t)setter_code, argc = (int32_t)argc_count; + bool utc = (setter & SCR_DATE_SET_UTC) != 0; + int32_t field = setter & ~SCR_DATE_SET_UTC; + ScrDateParts p; + if (isnan(t)) { + if (field != SCR_DATE_SET_FULL_YEAR) return NAN; + p = (ScrDateParts){1970, 0, 1, 4, 0, 0, 0, 0, 0}; + } else if (utc) { + if (!scr_date_utc_parts(t, &p)) return NAN; + } else if (!scr_date_local_parts(t, &p)) { + return NAN; + } + double y = (double)p.year, mo = p.month, dt = p.date; + double h = p.hours, mi = p.minutes, s = p.seconds, ms = p.milliseconds; + double args[4] = {a, b, c, d}; + double *slots[7] = {&y, &mo, &dt, &h, &mi, &s, &ms}; + /* The first field each setter names, in slots' order. */ + int first; + switch (field) { + case SCR_DATE_SET_MILLISECONDS: first = 6; break; + case SCR_DATE_SET_SECONDS: first = 5; break; + case SCR_DATE_SET_MINUTES: first = 4; break; + case SCR_DATE_SET_HOURS: first = 3; break; + case SCR_DATE_SET_DATE: first = 2; break; + case SCR_DATE_SET_MONTH: first = 1; break; + case SCR_DATE_SET_FULL_YEAR: first = 0; break; + default: return NAN; + } + /* Each setter takes at most the fields from its own down to the next + * larger unit's end: setHours up to milliseconds, setMonth up to the + * date, setFullYear up to the date. */ + int last = first <= 2 ? 2 : 6; + for (int i = 0; i < argc && first + i <= last && i < 4; i++) *slots[first + i] = args[i]; + double day = scr_date_v8_make_day(y, mo, dt); + double time = scr_date_v8_make_time(h, mi, s, ms); + if (!utc) return scr_date_from_local(day, time); + if (!isfinite(day) || !isfinite(time)) return NAN; + return scr_date_new_ms(day * 86400000.0 + time); +} + /* ── V8's date-string parser, the fallback ───────────────────────────── * scr_date_parse answers the bounded grammars directly and asks * this parser only when they produce NaN. It follows V8's DateParser @@ -13203,6 +13253,27 @@ ScrDate *scr_dyn_native_date_check(const ScrDyn *value, const ScrDynPath *path) static const char scr_date_to_date_string_name[] = "toDateString"; static const char scr_date_to_time_string_name[] = "toTimeString"; +/* The field setters a checked-dynamic Date answers (scr_date_set_fields). */ +static const struct { + const char *name; + int code, max; +} scr_native_date_setters[] = { + {"setMilliseconds", SCR_DATE_SET_MILLISECONDS, 1}, + {"setSeconds", SCR_DATE_SET_SECONDS, 2}, + {"setMinutes", SCR_DATE_SET_MINUTES, 3}, + {"setHours", SCR_DATE_SET_HOURS, 4}, + {"setDate", SCR_DATE_SET_DATE, 1}, + {"setMonth", SCR_DATE_SET_MONTH, 2}, + {"setFullYear", SCR_DATE_SET_FULL_YEAR, 3}, + {"setUTCMilliseconds", SCR_DATE_SET_UTC | SCR_DATE_SET_MILLISECONDS, 1}, + {"setUTCSeconds", SCR_DATE_SET_UTC | SCR_DATE_SET_SECONDS, 2}, + {"setUTCMinutes", SCR_DATE_SET_UTC | SCR_DATE_SET_MINUTES, 3}, + {"setUTCHours", SCR_DATE_SET_UTC | SCR_DATE_SET_HOURS, 4}, + {"setUTCDate", SCR_DATE_SET_UTC | SCR_DATE_SET_DATE, 1}, + {"setUTCMonth", SCR_DATE_SET_UTC | SCR_DATE_SET_MONTH, 2}, + {"setUTCFullYear", SCR_DATE_SET_UTC | SCR_DATE_SET_FULL_YEAR, 3}, +}; + static ScrDyn *scr_native_date_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { (void)self; (void)what; @@ -13232,6 +13303,25 @@ static ScrDyn *scr_native_date_invoke(void *ptr, ScrDyn *self, const char *metho ((ScrDate *)ptr)->ms = scr_date_new_ms(time); return scr_dyn_new_num(((ScrDate *)ptr)->ms); } + for (size_t i = 0; i < sizeof scr_native_date_setters / sizeof *scr_native_date_setters; i++) { + if (strcmp(method, scr_native_date_setters[i].name)) continue; + /* V8's order: ToNumber of the first argument, then the time value, + * then (unless the date is invalid) ToNumber of the optional ones the + * setter takes. */ + int code = scr_native_date_setters[i].code, max = scr_native_date_setters[i].max; + double values[4] = {NAN, 0, 0, 0}; + if (argc > 0 && !scr_dyn_number_coerce_js(args[0], &values[0])) return NULL; + double t = ((ScrDate *)ptr)->ms; + int used = argc < (size_t)max ? (int)argc : max; + if (used < 1) used = 1; + if (!isnan(t) || (code & ~SCR_DATE_SET_UTC) == SCR_DATE_SET_FULL_YEAR) { + for (int k = 1; k < used; k++) + if (!scr_dyn_number_coerce_js(args[k], &values[k])) return NULL; + } + double next = scr_date_set_fields(t, code, used, values[0], values[1], values[2], values[3]); + ((ScrDate *)ptr)->ms = next; + return scr_dyn_new_num(next); + } #define DATE_GET(name, fn) if (!strcmp(method, name)) return scr_dyn_new_num(fn(ms)) DATE_GET("getFullYear", scr_date_get_full_year_local); DATE_GET("getUTCFullYear", scr_date_get_full_year_utc); @@ -13311,7 +13401,9 @@ static const char *const scr_native_date_methods[] = { "getFullYear", "getUTCFullYear", "getMonth", "getUTCMonth", "getDate", "getUTCDate", "getDay", "getUTCDay", "getHours", "getUTCHours", "getMinutes", "getUTCMinutes", "getSeconds", "getUTCSeconds", "getMilliseconds", - "getUTCMilliseconds", "getTimezoneOffset", "setTime", + "getUTCMilliseconds", "getTimezoneOffset", "setTime", "setMilliseconds", "setSeconds", + "setMinutes", "setHours", "setDate", "setMonth", "setFullYear", "setUTCMilliseconds", + "setUTCSeconds", "setUTCMinutes", "setUTCHours", "setUTCDate", "setUTCMonth", "setUTCFullYear", }; #define SCR_NATIVE_DATE_METHODS (sizeof scr_native_date_methods / sizeof scr_native_date_methods[0]) static SCR_TL ScrDyn *scr_native_date_method_values[SCR_NATIVE_DATE_METHODS]; @@ -13397,12 +13489,40 @@ static ScrDyn *scr_native_date_get(void *ptr, const char *key, size_t key_len) { return NULL; } +/* structuredClone of a Date: a new Date with the same time value. */ +static ScrDyn *scr_native_date_clone(void *h) { + return scr_dyn_native_date_from_ms(((ScrDate *)h)->ms); +} + +/* A Date crossing threads (postMessage, workerData) carries its time value + * in a plain allocation any thread may read and free. */ +static void *scr_native_date_share(void *h) { + double *ms = malloc(sizeof *ms); + if (!ms) scr_trap("scriptc: out of memory\n"); + *ms = ((ScrDate *)h)->ms; + return ms; +} + +static ScrDyn *scr_native_date_adopt(void *payload) { + return scr_dyn_native_date_from_ms(*(double *)payload); +} + +static void scr_native_date_drop(void *payload) { free(payload); } + +static const ScrDynHandleTransfer scr_native_date_transfer = { + .share = scr_native_date_share, .adopt = scr_native_date_adopt, .drop = scr_native_date_drop}; + /* A checked-dynamic handle for the Date object `date` (borrowed; the * handle retains it), so the value stays the same object. */ ScrDyn *scr_dyn_native_date(ScrDate *date) { static const ScrDynHandleOps ops = { - "Date", &scr_date_retain_v, &scr_date_release_v, &scr_native_date_invoke, - &scr_native_date_get, NULL, NULL, NULL, + .cls = "Date", + .retain = &scr_date_retain_v, + .release = &scr_date_release_v, + .invoke = &scr_native_date_invoke, + .get = &scr_native_date_get, + .clone = &scr_native_date_clone, + .transfer = &scr_native_date_transfer, }; scr_dyn_handle_install(SCR_DYNH_DATE, &ops); return scr_dyn_new_handle(date, SCR_DYNH_DATE); diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index a71b0ccfd..39564c061 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -111,6 +111,23 @@ double scr_date_now(void); /* integer ms since epoch, like Node */ extern SCR_TL const double *scr_date_mock_now; double scr_date_new_ms(double ms); /* TimeClip (NaN when invalid) */ double scr_date_get_time(double ms); +/* Date.prototype's setters (setMilliseconds ... setFullYear and their UTC + * forms) over a time value: the new time value for `argc` arguments + * `a`..`d` (scr_lib.c). `setter` is an SCR_DATE_SET_* code; it and `argc` + * are whole numbers passed as doubles, as the compiler's lib calls pass + * numbers. */ +enum { + SCR_DATE_SET_MILLISECONDS = 0, + SCR_DATE_SET_SECONDS = 1, + SCR_DATE_SET_MINUTES = 2, + SCR_DATE_SET_HOURS = 3, + SCR_DATE_SET_DATE = 4, + SCR_DATE_SET_MONTH = 5, + SCR_DATE_SET_FULL_YEAR = 6, + SCR_DATE_SET_UTC = 8, +}; +double scr_date_set_fields(double t, double setter, double argc, + double a, double b, double c, double d); /* A Date object: the TimeClip'd time value in milliseconds (NaN for an * invalid date). Dates are immutable; their identity is the pointer. * Retain/release follow the runtime's RC conventions (SIZE_MAX immortal). */ diff --git a/packages/runtime/src/scr_qs.h b/packages/runtime/src/scr_qs.h index fc5ca5da5..2232d2d48 100644 --- a/packages/runtime/src/scr_qs.h +++ b/packages/runtime/src/scr_qs.h @@ -62,6 +62,8 @@ bool scr_util_terminal_colors(void); * (scr_util_promisify.c). Arguments are borrowed; function results are +1 * native dyn functions, or NULL with Node's TypeError pending. */ void scr_util_inherits(struct ScrDyn *ctor, struct ScrDyn *super_ctor); +void scr_util_inherits_prototype(struct ScrDyn *ctor, struct ScrDyn *super_ctor, + struct ScrDyn *super_prototype); struct ScrDyn *scr_util_deprecate(struct ScrDyn *fn, struct ScrDyn *msg, struct ScrDyn *code, struct ScrDyn *options); struct ScrDyn *scr_util_callbackify(struct ScrDyn *original); diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index ec061e10c..552fef146 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6634,6 +6634,12 @@ ScrStream *scr_stream_on_dyn(ScrStream *s, ScrStr *name, ScrDyn *cb, bool once, return (ScrStream *)scr_emitter_on_dyn((ScrEmitter *)s, name, cb, adapter, error ? scr_ee_inv_fixed1 : scr_ee_inv_fixed0, once, prepend); } + /* The stream emits 'pipe' and 'unpipe' with its source stream; other + * names reach the listener only through emit calls, with every argument + * they pass. */ + bool piping = (name->len == 4 && memcmp(name->data, "pipe", 4) == 0) || + (name->len == 6 && memcmp(name->data, "unpipe", 6) == 0); + if (!piping) return (ScrStream *)scr_emitter_on_flex((ScrEmitter *)s, name, cb, once, prepend); static const char message[] = "this stream event has no checked-dynamic listener adapter"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return NULL; @@ -8058,10 +8064,83 @@ static ScrStream *scr_stream_view_receiver(const ScrDyn *value) { /* The members a native stream answers to checked-dynamic calls. */ static const char *const scr_stream_dyn_methods[] = { "on", "once", "addListener", "prependListener", "prependOnceListener", - "off", "removeListener", "pipe", "unpipe", "read", "pause", "resume", "isPaused", - "setEncoding", "write", "end", "destroy", "cork", "uncork", + "off", "removeListener", "removeAllListeners", "listenerCount", "eventNames", "pipe", + "unpipe", "read", "push", "unshift", "pause", "resume", "isPaused", "setEncoding", "write", + "end", "destroy", "cork", "uncork", "setDefaultEncoding", }; +/* An event name argument: a string, +1; other values throw the "not + * supported yet" Error. */ +static ScrStr *scr_stream_dyn_event_name(const ScrDyn *value) { + if (value->kind == SCR_DYN_STR) return scr_str_retain(value->v.str); + static const char message[] = "event names other than strings on a native stream are not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; +} + +/* push(chunk, encoding): a string with an encoding pushes the string's + * bytes in that encoding, undefined pushes an empty chunk outside object + * mode, and other chunks push as push(chunk) does. */ +static ScrDyn *scr_stream_dyn_push(ScrStream *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *chunk = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + if (chunk->kind == SCR_DYN_STR && encoding->kind == SCR_DYN_STR) { + ScrStr *canonical = scr_bytes_normalize_encoding(encoding->v.str); + if (!canonical) return NULL; + bool more = scr_stream_push_str_enc(stream, chunk->v.str, canonical); + scr_str_release(canonical); + return scr_dyn_new_bool(more); + } + if (chunk->kind == SCR_DYN_UNDEF && !(stream->st->has_r && stream->st->r.object_entries)) { + ScrStr *empty = scr_str_new("", 0); + bool more = scr_stream_push_str(stream, empty); + scr_str_release(empty); + return scr_dyn_new_bool(more); + } + bool more = scr_stream_push_dyn(stream, chunk); + return scr_exc_pending() ? NULL : scr_dyn_new_bool(more); +} + +/* unshift(chunk[, encoding]): a byte-mode stream takes a string chunk with + * an encoding as the bytes Buffer.from(chunk, encoding) holds, as Node's + * readableAddChunkUnshift converts it; an unknown encoding throws + * ERR_UNKNOWN_ENCODING. Answers whether the stream takes more data + * afterwards, Node's canPushMore. */ +static ScrDyn *scr_stream_dyn_unshift(ScrStream *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *chunk = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + if (chunk->kind == SCR_DYN_STR && encoding->kind == SCR_DYN_STR && + !(stream->st->has_r && stream->st->r.object_entries)) { + ScrStr *canonical = scr_bytes_normalize_encoding(encoding->v.str); + if (!canonical) return NULL; + ScrBytes *b = scr_stream_buffer(scr_bytes_from_str(chunk->v.str, canonical)); + scr_str_release(canonical); + scr_stream_unshift(stream, b); + scr_bytes_release(b); + } else { + scr_stream_unshift_dyn(stream, chunk); + } + if (scr_exc_pending()) return NULL; + ScrStreamState *st = stream->st; + bool more = chunk->kind != SCR_DYN_NULL && st->has_r && !st->destroyed && !st->errored && + !st->r.ended && (st->r.length < st->r.hwm || st->r.length == 0); + return scr_dyn_new_bool(more); +} + +/* eventNames(): the stream's event names as a string array, +1. */ +static ScrDyn *scr_stream_dyn_event_names(ScrStream *stream) { + ScrArr *names = scr_emitter_event_names((ScrEmitter *)stream); + ScrDyn *result = scr_dyn_new_arr(); + double length = scr_arr_len(names); + for (double i = 0; i < length; i++) { + ScrStr *name = scr_arr_get_ref(names, i); + scr_dyn_arr_push(result, scr_dyn_new_str(name)); + scr_str_release(name); + } + scr_arr_release(names); + return result; +} + /* A checked-dynamic call of one of a native stream's members (borrowed * stream): the result, +1, with `this` results answering `self`, or the * current `this` when self is NULL. */ @@ -8086,6 +8165,29 @@ static ScrDyn *scr_stream_dyn_call(ScrStream *stream, ScrDyn *self, const char * argc > 1 ? args[1] : scr_dyn_undefined()); scr_emitter_release(registered); } + } else if (!strcmp(method, "removeAllListeners")) { + bool all = first->kind == SCR_DYN_UNDEF; + ScrStr *name = all ? scr_str_new("", 0) : scr_stream_dyn_event_name(first); + if (name) { + scr_emitter_release(scr_emitter_remove_all((ScrEmitter *)stream, name, all)); + scr_str_release(name); + } + } else if (!strcmp(method, "listenerCount")) { + ScrStr *name = scr_stream_dyn_event_name(first); + if (name) { + ScrDyn *only = argc > 1 ? args[1] : scr_dyn_undefined(); + double count = only->kind == SCR_DYN_UNDEF || only->kind == SCR_DYN_NULL + ? scr_emitter_listener_count((ScrEmitter *)stream, name) + : only->kind == SCR_DYN_FUNC ? scr_emitter_listener_count_dyn((ScrEmitter *)stream, name, only) : 0; + scr_str_release(name); + if (!scr_exc_pending()) result = scr_dyn_new_num(count); + } + } else if (!strcmp(method, "eventNames")) result = scr_stream_dyn_event_names(stream); + else if (!strcmp(method, "push")) result = scr_stream_dyn_push(stream, args, argc); + else if (!strcmp(method, "unshift")) result = scr_stream_dyn_unshift(stream, args, argc); + else if (!strcmp(method, "setDefaultEncoding")) { + ScrStream *returned = scr_stream_set_default_encoding(stream, first); + if (returned) scr_stream_release(returned); } else if (!strcmp(method, "pipe") || !strcmp(method, "unpipe")) { ScrStream *destination = first->kind == SCR_DYN_UNDEF && !strcmp(method, "unpipe") ? NULL : scr_stream_view_receiver(first); @@ -8220,6 +8322,11 @@ ScrDyn *scr_stream_dyn_view(ScrStream *stream) { const char *name = scr_stream_dyn_properties[i]; scr_dyn_obj_set(view, name, strlen(name), scr_stream_dyn_property(stream, name)); } + if (stream->st->has_r) { + int flowing = stream->st->r.flowing; + scr_dyn_obj_set(view, "readableFlowing", 15, + flowing < 0 ? scr_dyn_new_null() : scr_dyn_new_bool(flowing == 1)); + } if (stream->st->native && stream->st->native_ops && stream->st->native_ops->view) { ScrClosure *cb = scr_closure_new((void *)scr_stream_view_call, 2); cb->caps[0] = scr_box_new_obj(scr_stream_retain_v, scr_stream_release_v, scr_stream_trace); diff --git a/packages/runtime/src/scr_text_codec_dyn.c b/packages/runtime/src/scr_text_codec_dyn.c index 5102bc558..37fdfb27b 100644 --- a/packages/runtime/src/scr_text_codec_dyn.c +++ b/packages/runtime/src/scr_text_codec_dyn.c @@ -1,9 +1,10 @@ -/* WHATWG TextEncoder and TextDecoder instances as checked-dynamic values - * (SCR_DYNH_TEXT_ENCODER, SCR_DYNH_TEXT_DECODER). A codec is a compiled +/* WHATWG TextEncoder and TextDecoder instances, and node:string_decoder + * StringDecoder instances, as checked-dynamic values (SCR_DYNH_TEXT_ENCODER, + * SCR_DYNH_TEXT_DECODER, SCR_DYNH_STRING_DECODER). A codec is a compiled * record (type-mapper.ts): the handle holds that record by reference, so * a codec stored in an untyped slot and read back keeps its identity and * a decoder keeps its streaming state. The compiler passes the record's - * reference-count entry points and, for decoders, the byte offsets of the + * reference-count entry points and, for a TextDecoder, the byte offsets of the * encoding, fatal, ignoreBOM and state fields when it boxes one. A unit * of its own so programs that never box a codec link none of it * (native-toolchain gating: the textCodecDyn runtime feature). */ @@ -213,3 +214,124 @@ bool scr_text_codec_is(const ScrDyn *d, bool decoder) { return d->kind == SCR_DYN_HANDLE && d->v.handle.tag == (decoder ? SCR_DYNH_TEXT_DECODER : SCR_DYNH_TEXT_ENCODER); } + +/* node:string_decoder StringDecoder instances (SCR_DYNH_STRING_DECODER). + * The decoder is a compiled record of its canonical encoding (a string) + * and its pending partial sequence (an f64 packing, scr_bytes.c's + * scr_strdec_* helpers); the compiler passes both fields' byte offsets + * when it boxes one. */ +typedef struct { + void *(*retain)(void *); + void (*release)(void *); + size_t encoding, pending; +} ScrStringDecoderLayout; + +static SCR_TL ScrStringDecoderLayout scr_sdd_layout; + +static void *scr_sdd_retain(void *rec) { return scr_sdd_layout.retain(rec); } +static void scr_sdd_release(void *rec) { scr_sdd_layout.release(rec); } + +static ScrStr *scr_sdd_encoding(const void *rec) { + ScrStr *encoding; + memcpy(&encoding, (const char *)rec + scr_sdd_layout.encoding, sizeof encoding); + return encoding; +} + +static double scr_sdd_pending(const void *rec) { + double pending; + memcpy(&pending, (const char *)rec + scr_sdd_layout.pending, sizeof pending); + return pending; +} + +static void scr_sdd_set_pending(void *rec, double pending) { + memcpy((char *)rec + scr_sdd_layout.pending, &pending, sizeof pending); +} + +/* write(buf): a string answers itself; an ArrayBuffer view decodes with + * the pending bytes before it; other values throw Node's + * ERR_INVALID_ARG_TYPE. +1 or NULL. */ +static ScrStr *scr_sdd_write(void *rec, const ScrDyn *buf) { + if (buf->kind == SCR_DYN_STR) return scr_str_retain(buf->v.str); + if (buf->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", buf); + return NULL; + } + ScrBytes *bytes = scr_bytes_raw_view(buf->v.bytes); + ScrStr *encoding = scr_sdd_encoding(rec); + double pending = scr_sdd_pending(rec); + ScrStr *text = scr_strdec_write(encoding, pending, bytes); + scr_sdd_set_pending(rec, scr_strdec_next(encoding, pending, bytes)); + scr_bytes_release(bytes); + return text; +} + +static ScrDyn *scr_sdd_invoke(void *rec, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + (void)self; + ScrDyn *buf = scr_tcd_codec_arg(args, argc, 0); + if (strcmp(method, "write") == 0) { + ScrStr *text = scr_sdd_write(rec, buf); + return text ? scr_tcd_str(text) : NULL; + } + if (strcmp(method, "end") == 0) { + /* end(buf): write(buf) when buf is not undefined, then the pending + * bytes' replacement text. */ + ScrStr *head = buf->kind == SCR_DYN_UNDEF ? scr_str_new("", 0) : scr_sdd_write(rec, buf); + if (!head) return NULL; + ScrStr *tail = scr_strdec_end(scr_sdd_encoding(rec), scr_sdd_pending(rec)); + scr_sdd_set_pending(rec, 0); + ScrStr *text = scr_str_concat(head, tail); + scr_str_release(head); + scr_str_release(tail); + return scr_tcd_str(text); + } + if (strcmp(method, "text") == 0) { + static const char message[] = "StringDecoder.prototype.text is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; + } + scr_tcd_codec_not_a_function(what); + return NULL; +} + +static ScrDyn *scr_sdd_get(void *rec, const char *key, size_t len) { + if (len == 8 && memcmp(key, "encoding", 8) == 0) + return scr_dyn_new_str(scr_sdd_encoding(rec)); + if ((len == 8 && memcmp(key, "lastChar", 8) == 0) || + (len == 8 && memcmp(key, "lastNeed", 8) == 0) || + (len == 9 && memcmp(key, "lastTotal", 9) == 0)) { + static const char message[] = "StringDecoder lastChar, lastNeed and lastTotal are not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + } + return NULL; +} + +static const ScrDynHandleMethod scr_sdd_methods[] = { + {.name = "write", .length = 1}, + {.name = "end", .length = 1}, + {.name = "text", .length = 2}, + {.name = NULL}, +}; + +static const ScrDynHandleOps scr_sdd_ops = { + .cls = "StringDecoder", + .retain = scr_sdd_retain, + .release = scr_sdd_release, + .invoke = scr_sdd_invoke, + .get = scr_sdd_get, + .set = scr_tcd_codec_set, + .methods = scr_sdd_methods, +}; + +ScrDyn *scr_string_decoder_to_dyn(void *rec, void *(*retain)(void *), void (*release)(void *), + size_t encoding, size_t pending) { + if (!scr_sdd_layout.retain) { + scr_sdd_layout = (ScrStringDecoderLayout){retain, release, encoding, pending}; + scr_dyn_handle_install(SCR_DYNH_STRING_DECODER, &scr_sdd_ops); + } + return scr_dyn_new_handle(rec, SCR_DYNH_STRING_DECODER); +} + +void *scr_string_decoder_from_dyn(const ScrDyn *d, const ScrDynPath *path, const char *want) { + return scr_dyn_handle_unbox(d, SCR_DYNH_STRING_DECODER, path, want); +} diff --git a/packages/runtime/src/scr_unwind.c b/packages/runtime/src/scr_unwind.c new file mode 100644 index 000000000..50474e82e --- /dev/null +++ b/packages/runtime/src/scr_unwind.c @@ -0,0 +1,83 @@ +/* scr_unwind_raise: the native unwind of the unwinding exception ABI of + * emitted functions (the compiler's unwind-abi.ts). Linux (glibc and musl) + * only; elsewhere this unit is empty. */ +#include "scr_runtime.h" + +#if defined(__linux__) && !defined(__ANDROID__) && !defined(__wasi__) +#include +#include +#include +#include +#include + +/* Weak, so the runtime unit alone does not link the unwinder into every + * program: programs that raise reference the personality routine from + * their landing pads, and its unit references the unwinder's entry points, + * which link it in. */ +#pragma weak _Unwind_ForcedUnwind +#pragma weak _Unwind_GetCFA + +#if defined(__SANITIZE_ADDRESS__) +#define SCR_UNWIND_ASAN 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define SCR_UNWIND_ASAN 1 +#endif +#endif +#ifdef SCR_UNWIND_ASAN +#include +#endif + +#ifdef SCR_UNWIND_ASAN +/* The lowest stack address the unwind has not cleared yet. The unwind + * outlives the frame that started it: landing pads resume it. */ +static SCR_TL uintptr_t scr_unwind_low; +#endif + +/* Emitted landing pads are cleanups: they release the frame's references + * and then either take the exception (a handler of theirs, or a frame on + * the pending-flag ABI that returns with it pending) or resume the unwind. + * So the unwind is forced, a single pass through every landing pad; the + * exception itself stays in the active cell. */ +static _Unwind_Reason_Code scr_unwind_stop(int version, _Unwind_Action actions, + _Unwind_Exception_Class cls, + struct _Unwind_Exception *exc, + struct _Unwind_Context *context, void *arg) { + (void)version; + (void)cls; + (void)exc; +#ifdef SCR_UNWIND_ASAN + /* The frames this unwind passes never return, so it clears their + * redzones' shadow itself. Emitted code calls scr_unwind_raise as an + * ordinary function: the sanitizer's hook before no-return calls needs + * stack bounds, which fiber stacks do not report. */ + uintptr_t cfa = _Unwind_GetCFA(context); + if (cfa > scr_unwind_low) { + __asan_unpoison_memory_region((void *)scr_unwind_low, cfa - scr_unwind_low); + scr_unwind_low = cfa; + } +#else + (void)context; +#endif + (void)arg; + if (actions & _UA_END_OF_STACK) { + fputs("scriptc: an exception unwound past the program's outermost frame\n", stderr); + abort(); + } + return _URC_NO_REASON; +} + +static SCR_TL struct _Unwind_Exception scr_unwind_exception; + +void scr_unwind_raise(void) { + struct _Unwind_Exception *exc = &scr_unwind_exception; + memset(exc, 0, sizeof *exc); + exc->exception_class = 0x5343524950540000ull; /* "SCRIPT\0\0" */ +#ifdef SCR_UNWIND_ASAN + scr_unwind_low = (uintptr_t)__builtin_frame_address(0); +#endif + if (_Unwind_ForcedUnwind != NULL) _Unwind_ForcedUnwind(exc, scr_unwind_stop, NULL); + fputs("scriptc: the native unwinder could not raise an exception\n", stderr); + abort(); +} +#endif diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c index c905ea8ae..60de92121 100644 --- a/packages/runtime/src/scr_util_callable.c +++ b/packages/runtime/src/scr_util_callable.c @@ -149,23 +149,9 @@ static bool uc_define(ScrDyn *target, const char *key, ScrDyn *descriptor) { * Node 24's lib/util.js: nullish arguments and an undefined * superCtor.prototype throw ERR_INVALID_ARG_TYPE; then super_ becomes a * writable, configurable, non-enumerable own property and - * ctor.prototype's prototype becomes superCtor.prototype. */ -void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { - if (uc_nullish(ctor)) { - scr_dyn_arg_type_fail("ctor", "of type function", ctor); - return; - } - if (uc_nullish(super_ctor)) { - scr_dyn_arg_type_fail("superCtor", "of type function", super_ctor); - return; - } - if ((ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) || - (super_ctor->kind == SCR_DYN_FUNC && super_ctor->v.fn.class_obj)) { - uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); - return; - } - ScrDyn *super_prototype = uc_get(super_ctor, "prototype"); - if (!super_prototype) return; + * ctor.prototype's prototype becomes superCtor.prototype. super_prototype + * is superCtor.prototype (owned), or NULL when superCtor is no object. */ +static void uc_inherits(ScrDyn *ctor, ScrDyn *super_ctor, ScrDyn *super_prototype) { if (super_prototype->kind == SCR_DYN_UNDEF) { scr_dyn_prop_type_fail("superCtor.prototype", "of type object", super_prototype); scr_dyn_release(super_prototype); @@ -198,6 +184,40 @@ void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { scr_dyn_release(super_prototype); } +void scr_util_inherits(ScrDyn *ctor, ScrDyn *super_ctor) { + if (uc_nullish(ctor)) { + scr_dyn_arg_type_fail("ctor", "of type function", ctor); + return; + } + if (uc_nullish(super_ctor)) { + scr_dyn_arg_type_fail("superCtor", "of type function", super_ctor); + return; + } + if ((ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) || + (super_ctor->kind == SCR_DYN_FUNC && super_ctor->v.fn.class_obj)) { + uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); + return; + } + ScrDyn *super_prototype = uc_get(super_ctor, "prototype"); + if (!super_prototype) return; + uc_inherits(ctor, super_ctor, super_prototype); +} + +/* util.inherits(ctor, C) where C is a compiled class whose prototype the + * compiler exposes as super_prototype (borrowed): the class value's own + * properties do not hold it. ctor itself may not be a class. */ +void scr_util_inherits_prototype(ScrDyn *ctor, ScrDyn *super_ctor, ScrDyn *super_prototype) { + if (uc_nullish(ctor)) { + scr_dyn_arg_type_fail("ctor", "of type function", ctor); + return; + } + if (ctor->kind == SCR_DYN_FUNC && ctor->v.fn.class_obj) { + uc_fence("util.inherits over class constructors has no lowering; declare the subclass with extends"); + return; + } + uc_inherits(ctor, super_ctor, scr_dyn_retain(super_prototype)); +} + /* ── util.deprecate ──────────────────────────────────────────────────── * The wrapper warns once (and once per code across wrappers) before each * call reaches fn with the call's receiver and arguments; construction diff --git a/packages/runtime/test/json.test.ts b/packages/runtime/test/json.test.ts index 3315cb415..b08f2fdd3 100644 --- a/packages/runtime/test/json.test.ts +++ b/packages/runtime/test/json.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -53,9 +53,8 @@ beforeAll(async () => { // The C side parses/inspects dyn values as checks and routes each expected // failure through scr_exc_print_uncaught. Parse failures are SyntaxError // INSTANCES (the depth cap a RangeError, dynCheck failures TypeErrors), so -// every uncaught line renders "name: message" — e.name is Node-exact while -// the V8-flavored message wording stays approximate (SEMANTICS.md): these -// lines pin OUR rendering, not Node's. +// every uncaught line renders "name: message"; the JSON syntax error +// messages are Node's (V8's wording, positions and line/column). test("json runtime: parser, dyn, dynCheck failure path, stringify buffer", async () => { const { stdout, stderr } = await execFileAsync(bin); expect(stdout).toMatch(/^(\d+)\/\1 checks passed\n$/); @@ -63,21 +62,21 @@ test("json runtime: parser, dyn, dynCheck failure path, stringify buffer", async [ `Uncaught SyntaxError: Unexpected end of JSON input`, `Uncaught SyntaxError: Unexpected end of JSON input`, - `Uncaught SyntaxError: Expected property name or '}' in JSON at position 1`, + `Uncaught SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)`, + `Uncaught SyntaxError: Unexpected end of JSON input`, + `Uncaught SyntaxError: Expected ',' or ']' after array element in JSON at position 3 (line 1 column 4)`, + `Uncaught SyntaxError: Expected ':' after property name in JSON at position 4 (line 1 column 5)`, + `Uncaught SyntaxError: Expected double-quoted property name in JSON at position 7 (line 1 column 8)`, + `Uncaught SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)`, + `Uncaught SyntaxError: Unterminated string in JSON at position 13 (line 1 column 14)`, + `Uncaught SyntaxError: Bad escaped character in JSON at position 6 (line 1 column 7)`, + `Uncaught SyntaxError: Bad control character in string literal in JSON at position 6 (line 1 column 7)`, + `Uncaught SyntaxError: Bad Unicode escape in JSON at position 5 (line 1 column 6)`, + `Uncaught SyntaxError: No number after minus sign in JSON at position 1 (line 1 column 2)`, + `Uncaught SyntaxError: Unterminated fractional number in JSON at position 2 (line 1 column 3)`, + `Uncaught SyntaxError: Exponent part is missing a number in JSON at position 3 (line 1 column 4)`, + `Uncaught SyntaxError: Unexpected non-whitespace character after JSON at position 2 (line 1 column 3)`, `Uncaught SyntaxError: Unexpected end of JSON input`, - `Uncaught SyntaxError: Expected ',' or ']' after array element in JSON at position 3`, - `Uncaught SyntaxError: Expected ':' after property name in JSON at position 4`, - `Uncaught SyntaxError: Expected property name or '}' in JSON at position 7`, - `Uncaught SyntaxError: Expected property name or '}' in JSON at position 1`, - `Uncaught SyntaxError: Unterminated string in JSON at position 0`, - `Uncaught SyntaxError: Bad escaped character in JSON at position 6`, - `Uncaught SyntaxError: Bad control character in string literal in JSON at position 6`, - `Uncaught SyntaxError: Bad Unicode escape in JSON at position 5`, - `Uncaught SyntaxError: No number after minus sign in JSON at position 1`, - `Uncaught SyntaxError: Unterminated fractional number in JSON at position 2`, - `Uncaught SyntaxError: Exponent part is missing a number in JSON at position 3`, - `Uncaught SyntaxError: Unexpected non-whitespace character after JSON at position 2`, - `Uncaught SyntaxError: Unexpected token 'n', "nul" is not valid JSON`, `Uncaught RangeError: Maximum call stack size exceeded`, `Uncaught TypeError: expected number at $.items[2].price, got string`, `Uncaught TypeError: expected object at $, got undefined`, @@ -85,3 +84,32 @@ test("json runtime: parser, dyn, dynCheck failure path, stringify buffer", async ].join("\n"), ); }); + +function zigOnPath(): boolean { + try { + execFileSync("zig", ["version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +// 32-bit uintptr_t: the weak-collection hashes mix in 64 bits, so no shift +// exceeds the width of its operand on wasm32. +test.skipIf(!zigOnPath())("json runtime builds for wasm32 without oversized shifts", async () => { + await execFileAsync("zig", [ + "cc", + "-target", + "wasm32-wasi", + "-std=c11", + "-O2", + "-D_GNU_SOURCE", + "-D_WASI_EMULATED_SIGNAL", + "-D_WASI_EMULATED_PROCESS_CLOCKS", + "-Werror=shift-count-overflow", + "-c", + "-o", + join(testDir, "build", "scr_json.wasm32.o"), + join(testDir, "../src/scr_json.c"), + ]); +}); diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 9247585e5..b23f61a0a 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -395,8 +395,10 @@ export function dynamicTierSymbols(names) { * bench/channel-messages and bench/worker-messages, added later, link them * through worker messages: * node:worker_threads values are dyn objects. bench/json-unions, added - * later too, decodes JSON.parse trees. bench/dynamic-methods is - * untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and + * later too, decodes JSON.parse trees. bench/throw-chains, also added + * later, tests caught values with `instanceof`, as bench/validate-errors + * does. bench/dynamic-methods and + * bench/dynamic-properties are untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here * when a leak fix makes its binary clean. */ @@ -411,11 +413,13 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/thread-slots", "bench/channel-messages", "bench/dynamic-methods", + "bench/dynamic-properties", "bench/worker-messages", "bench/typed-array-scan-workers", "bench/log-summary", "bench/inventory-report", "bench/json-unions", + "bench/throw-chains", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O diff --git a/tests/corpus/1004-json-parse-errors.ts b/tests/corpus/1004-json-parse-errors.ts index e65a9752e..aad0817d7 100644 --- a/tests/corpus/1004-json-parse-errors.ts +++ b/tests/corpus/1004-json-parse-errors.ts @@ -1,9 +1,7 @@ // JSON.parse syntax errors are CATCHABLE, exactly like Node's SyntaxError. -// The message text itself is unobservable here (the supported catch form is -// bindingless) and its V8-flavored shape is deliberately approximate — -// exact-message assertions live in the runtime C tests (test_json.c), per -// SEMANTICS.md. This program only proves the throw/catch/recover behavior -// matches Node for every syntax-error class. +// This program proves the throw/catch/recover behavior matches Node for +// every syntax-error class; 7993-json-parse-error-messages pins the +// messages themselves. function attempt(raw: string, label: string): void { try { diff --git a/tests/corpus/5570-fs-cp-async.ts b/tests/corpus/5570-fs-cp-async.ts index d83750095..23747bcb3 100644 --- a/tests/corpus/5570-fs-cp-async.ts +++ b/tests/corpus/5570-fs-cp-async.ts @@ -88,8 +88,13 @@ const stamp = new Date("2021-05-06T07:08:09.123Z"); fs.utimesSync("file.txt", stamp, stamp); await fsp.cp("file.txt", "p-stamped.txt", { preserveTimestamps: true }); console.log(new Date(fs.statSync("p-stamped.txt").mtimeMs).toISOString()); +// The source tree gets fixed times: its own times come from the clock, and +// whether a copy equals them depends on their sub-millisecond digits. +fs.utimesSync("src/a.txt", stamp, stamp); +fs.utimesSync("src", stamp, stamp); await fsp.cp("src", "p-stamped-dir", { recursive: true, preserveTimestamps: true }); -console.log(fs.statSync("p-stamped-dir").mtimeMs === fs.statSync("src").mtimeMs); +for (const path of ["p-stamped-dir", "p-stamped-dir/a.txt"]) + console.log(path, new Date(fs.statSync(path).mtimeMs).toISOString()); const rejected = await fsp.cp("missing", "x").then( () => "fulfilled", diff --git a/tests/corpus/6307-number-map-get-set-fusion.ts b/tests/corpus/6307-number-map-get-set-fusion.ts new file mode 100644 index 000000000..d373782ef --- /dev/null +++ b/tests/corpus/6307-number-map-get-set-fusion.ts @@ -0,0 +1,82 @@ +// A number-keyed Map read followed by a write of the same key: the write +// goes through the entry the read found when the map and the key's bits +// are the same, and through an ordinary set otherwise (a miss, -0 against +// 0, another map). Values, sizes and insertion order match Node for +// directly indexed keys, hashed keys, fractional keys, NaN and signed +// zeros. + +function show(map: Map): string { + const parts: string[] = []; + map.forEach((value, key) => { + parts.push(`${Object.is(key, -0) ? "-0" : String(key)}=${value}`); + }); + return `${map.size} [${parts.join(" ")}]`; +} + +// The counting update over small integer keys (direct index) and wide keys +// (hashed). +const keys = [3, 1, 3, 2, 1, 3, 1e12, 2 ** 40 + 1, 1e12, 0.5, 0.5, -7, 2 ** 40 + 1]; +const counts = new Map(); +for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1); +console.log(show(counts)); + +// The read as a declaration, then the write. +const sums = new Map(); +for (let i = 0; i < 40; i++) { + const key = (i * 7) % 11; + const current = sums.get(key); + sums.set(key, current === undefined ? i : current + i); +} +console.log(show(sums)); + +// Signed zeros: -0 and 0 are one key, which the map stores as 0. +const zeros = new Map(); +const zeroKeys = [-0, 0, -0, 0, 0]; +for (const key of zeroKeys) zeros.set(key, (zeros.get(key) ?? 0) + 1); +console.log(show(zeros), Object.is([...zeros.keys()][0], -0)); +const plusFirst = new Map(); +for (const key of [0, -0, -0]) plusFirst.set(key, (plusFirst.get(key) ?? 0) + 1); +console.log(show(plusFirst)); + +// NaN is one key whatever its producer. +const nans = new Map(); +const nanKeys = [NaN, 0 / 0, Math.sqrt(-1), Number("x")]; +for (const key of nanKeys) nans.set(key, (nans.get(key) ?? 0) + 1); +console.log(show(nans)); + +// A read of one map and a write of another take the ordinary set. +const source = new Map([ + [1, 10], + [2, 20], +]); +const target = new Map([[2, 5]]); +for (const key of [1, 2, 3]) target.set(key, (source.get(key) ?? 0) + 1); +console.log(show(source), show(target)); + +// Overwrites keep the insertion order; deleted keys come back at the end. +const order = new Map(); +for (const key of [5, 4, 3, 2, 1]) order.set(key, (order.get(key) ?? 0) + 1); +order.delete(3); +for (const key of [1, 3, 5]) order.set(key, (order.get(key) ?? 0) + 10); +console.log(show(order)); + +// Boolean values. +const seen = new Map(); +for (const key of [1, 2, 1, 1e9, 1e9]) seen.set(key, !(seen.get(key) ?? false)); +const flags: string[] = []; +seen.forEach((value, key) => flags.push(`${key}:${value}`)); +console.log(flags.join(" ")); + +// A large table, where the hashed probe and growth run. +const big = new Map(); +for (let i = 0; i < 50000; i++) { + const key = (i * 2654435761) % 100003; + big.set(key, (big.get(key) ?? 0) + 1); +} +let total = 0; +let most = 0; +big.forEach((value) => { + total += value; + if (value > most) most = value; +}); +console.log(big.size, total, most); diff --git a/tests/corpus/7305-string-unit-loop-versions.ts b/tests/corpus/7305-string-unit-loop-versions.ts new file mode 100644 index 000000000..c7600167d --- /dev/null +++ b/tests/corpus/7305-string-unit-loop-versions.ts @@ -0,0 +1,219 @@ +// charCodeAt scans of strings that are not ASCII, repeated so the runtime +// holds their UTF-16 units: counts, hashes, neighbor reads, descending +// loops, reads past the end, line starts pushed to a number array, helper +// functions that read no string, a helper that reads another string, a +// second non-ASCII string read in the same loop, two texts scanned in +// turn, a short text, a text appended to between scans, surrogate pairs +// and lone surrogates, ASCII texts beside them, and while loops over a +// counter below the length: word and identifier scans, peeks past the +// counter, and a lexer class whose position is a field. Loops also read +// the length in their bodies, and loops over a fresh text that no scan +// has published run from its end and over its last units. + +function isWordUnit(c: number): boolean { + return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c > 127; +} + +function isWordOrDigit(c: number): boolean { + return isWordUnit(c) || (c >= 48 && c <= 57); +} + +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} + +function hash(s: string): number { + let h = 7; + for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0; + return h; +} + +function rises(s: string): number { + let n = 0; + for (let i = 1; i < s.length - 1; i++) + if (s.charCodeAt(i) > s.charCodeAt(i - 1) && s.charCodeAt(i) >= s.charCodeAt(i + 1)) n++; + return n; +} + +function backwards(s: string): number { + let h = 0; + for (let i = s.length - 1; i >= 0; i -= 3) h = (h * 7 + s.charCodeAt(i)) % 1000003; + return h; +} + +function pastEnd(s: string): string { + let sum = 0; + for (let i = 0; i <= s.length; i++) sum += s.charCodeAt(i); + return String(sum); +} + +function lineStarts(s: string): number[] { + const starts = [0]; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) starts.push(i + 1); + return starts; +} + +function wordUnits(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (isWordOrDigit(s.charCodeAt(i))) n++; + return n; +} + +function codeOf(t: string, i: number): number { + return t.charCodeAt(i % t.length); +} + +function mixed(s: string, t: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) n = (n + s.charCodeAt(i) * codeOf(t, i)) % 1000003; + return n; +} + +function sameAt(a: string, b: string): number { + let n = 0; + const end = Math.min(a.length, b.length); + for (let i = 0; i < end; i++) if (a.charCodeAt(i) === b.charCodeAt(i)) n++; + return n; +} + +function surrogates(s: string): string { + let high = 0, + low = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0xd800 && c < 0xdc00) high++; + else if (c >= 0xdc00 && c < 0xe000) low++; + } + return `${high}/${low}`; +} + +function wordCount(s: string): number { + let pos = 0, + n = 0; + const end = s.length; + while (pos < end) { + while (pos < end && s.charCodeAt(pos) === 32) pos++; + if (pos >= end) break; + n++; + while (pos < end && s.charCodeAt(pos) !== 32) pos++; + } + return n; +} + +function wordUnitRuns(s: string): number { + let pos = 0, + total = 0; + while (pos < s.length) { + if (isWordUnit(s.charCodeAt(pos))) { + const start = pos; + while (pos < s.length && isWordOrDigit(s.charCodeAt(pos))) pos++; + total += pos - start; + } else pos = (pos + 1) | 0; + } + return total; +} + +function doubled(s: string): number { + let pos = 0, + n = 0; + while (pos < s.length) { + if (s.charCodeAt(pos) === s.charCodeAt(pos + 1)) n++; + pos += 1; + } + return n; +} + +class Scanner { + pos = 0; + src: string; + constructor(src: string) { + this.src = src; + } + line(): number { + const src = this.src; + const start = this.pos; + let pos = start; + while (pos < src.length && src.charCodeAt(pos) !== 10) pos++; + this.pos = pos + 1; + return pos - start; + } +} + +function lineLengths(s: string): number { + const scanner = new Scanner(s); + let total = 0, + lines = 0; + while (scanner.pos < s.length) { + total = (total * 31 + scanner.line()) % 1000003; + lines++; + } + return total * 1000 + lines; +} + +function remaining(s: string): number { + let pos = 0; + let sum = 0; + while (pos < s.length) { + if (s.charCodeAt(pos) === 32) sum += s.length - pos; + pos++; + } + return sum; +} + +function lastLineLength(s: string): number { + let last = s.length; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) last = s.length - i - 1; + return last; +} + +function from(s: string, start: number): number { + let pos = start; + let sum = 0; + while (pos < s.length) { + sum = sum * 3 + s.charCodeAt(pos); + pos++; + } + return sum; +} + +const words = ["Größe", "naïve", "façade", "東京", "ok", "✓", "mañana", "plain", "😀", "end\n"]; +function text(count: number, seed: number): string { + let out = ""; + for (let k = 0; k < count; k++) out += words[(k * 7 + seed) % words.length] + (k % 5 === 0 ? " " : " "); + return out; +} + +const big = text(3000, 1); +const other = text(2000, 4); +const ascii = big.replace(/[^\x00-\x7f]/g, "x"); +const short = "café ✓ 東京"; +const lone = "a\uD800b\uDFFFc " + big.slice(0, 500) + "\uD83D"; +for (let pass = 0; pass < 3; pass++) { + console.log( + pass, + spaces(big), + hash(big), + rises(big), + backwards(big), + pastEnd(big), + lineStarts(big).length, + wordUnits(big), + ); + console.log(" ", spaces(other), hash(other), spaces(big), hash(other), wordUnits(other)); + console.log(" ", mixed(big, other), sameAt(big, other), surrogates(big), surrogates(lone)); + console.log(" ", spaces(ascii), hash(ascii), spaces(short), hash(short), hash(lone), rises(lone)); + console.log(" ", wordCount(big), wordUnitRuns(big), doubled(big), lineLengths(big)); + console.log(" ", wordCount(ascii), wordUnitRuns(other), doubled(lone), lineLengths(lone), wordCount("")); + const fresh = text(40 + pass, pass) + "ü"; + console.log(" ", remaining(big), remaining(ascii), lastLineLength(big), lastLineLength(other)); + console.log(" ", from(fresh, fresh.length), from(fresh, fresh.length - 3), from(big, big.length - 4)); +} + +let grown = text(1500, 2); +for (let round = 0; round < 3; round++) { + console.log("grown", round, grown.length, spaces(grown), hash(grown), hash(grown)); + grown += text(200, round) + "Ünïcödé"; +} +console.log("empty", spaces(""), hash(""), pastEnd(""), lineStarts("").length); diff --git a/tests/corpus/7307-string-unit-loop-evictions.ts b/tests/corpus/7307-string-unit-loop-evictions.ts new file mode 100644 index 000000000..94e98e5b4 --- /dev/null +++ b/tests/corpus/7307-string-unit-loop-evictions.ts @@ -0,0 +1,73 @@ +// A units copy must not run program code that indexes other strings: a +// comparator sort or a copy from an untyped source can evict the scanned +// text's UTF-16 units while the loop reads them. + +function bigText(seed: number): string { + let s = ""; + for (let i = 0; i < 9000; i++) s += String.fromCharCode(0xe9 + ((i * seed) % 7)) + "ab "; + return s; +} + +const others = [bigText(3), bigText(5), bigText(11), bigText(13), bigText(17)]; + +let touched = 0; +function touchOthers(): void { + for (let k = 0; k < others.length; k++) { + const o = others[k]; + for (let j = 0; j < 80; j++) touched += o.charCodeAt((j * 997 + k) % o.length); + } +} + +function spaces(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) n++; + return n; +} + +function sortedWhileScanning( + s: string, + values: Float64Array, + compare: (a: number, b: number) => number, +): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + sum += s.charCodeAt(i); + if (i % 4096 === 100) values.sort(compare); + } + return sum; +} + +function copiedWhileScanning(s: string, values: Float64Array, source: any): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + sum += s.charCodeAt(i); + if (i % 4096 === 100) values.set(source); + } + return sum; +} + +const text = bigText(7); +// Repeated scans make the runtime hold the text's units. +let warm = 0; +for (let r = 0; r < 4; r++) warm += spaces(text); +console.log("warm", warm); + +const values = new Float64Array([3, 1, 2]); +const byValue = (a: number, b: number): number => { + touchOthers(); + return a - b; +}; +console.log("sorted", sortedWhileScanning(text, values, byValue), values.join(",")); +for (let r = 0; r < 2; r++) warm += spaces(text); + +const source: any = { + length: 3, + get 0() { + touchOthers(); + return 9; + }, + 1: 8, + 2: 7, +}; +console.log("copied", copiedWhileScanning(text, values, source), values.join(",")); +console.log("touched", touched > 0, spaces(text)); diff --git a/tests/corpus/7742-weak-collections-many-keys.ts b/tests/corpus/7742-weak-collections-many-keys.ts new file mode 100644 index 000000000..2cb2b103e --- /dev/null +++ b/tests/corpus/7742-weak-collections-many-keys.ts @@ -0,0 +1,70 @@ +// Weak collections with many keys: entries found among tens of thousands +// in WeakMaps and WeakSets that share keys, deleted and replaced, and gone +// with their keys, so blocks a new key reuses are not found under it. +// Keys in reference cycles die by the cycle collector. +class Key { + readonly id: number; + peer: Key | null = null; + constructor(id: number) { + this.id = id; + } +} + +const n = 40000; +let keys: Key[] = []; +for (let i = 0; i < n; i++) keys.push(new Key(i)); +const values = new WeakMap(); +const labels = new WeakMap(); +const marked = new WeakSet(); +for (const k of keys) { + values.set(k, k.id * 2); + if (k.id % 3 === 0) labels.set(k, "k" + k.id); + if (k.id % 2 === 0) marked.add(k); +} +let sum = 0; +let labelled = 0; +let counted = 0; +for (const k of keys) { + sum += values.get(k) ?? 0; + if (labels.has(k)) labelled++; + if (marked.has(k)) counted++; +} +console.log("filled", sum, labelled, counted); + +// Deleting every fourth key from one collection leaves the others alone. +let deleted = 0; +for (const k of keys) if (k.id % 4 === 0 && values.delete(k)) deleted++; +let remaining = 0; +for (const k of keys) if (values.has(k)) remaining++; +console.log("deleted", deleted, remaining, values.delete(keys[0]!), marked.has(keys[0]!), labels.get(keys[3]!)); + +// Overwriting keeps one entry per key. +for (const k of keys) if (k.id % 5 === 0) values.set(k, -1); +let negative = 0; +for (const k of keys) if (values.get(k) === -1) negative++; +console.log("overwritten", negative); + +// Dropping the keys ends their entries; new keys are not found. +keys = []; +const fresh: Key[] = []; +for (let i = 0; i < n; i++) fresh.push(new Key(n + i)); +let stale = 0; +for (const k of fresh) if (values.has(k) || labels.has(k) || marked.has(k)) stale++; +console.log("fresh", fresh.length, stale); + +// Keys that reach each other die together through the cycle collector. +for (let round = 0; round < 3; round++) { + let ring: Key[] = []; + for (let i = 0; i < 1000; i++) ring.push(new Key(i)); + for (let i = 0; i < ring.length; i++) ring[i]!.peer = ring[(i + 1) % ring.length]!; + for (const k of ring) { + values.set(k, round); + marked.add(k); + } + ring = []; +} +const after: Key[] = []; +for (let i = 0; i < 3000; i++) after.push(new Key(i)); +let found = 0; +for (const k of after) if (values.has(k) || marked.has(k)) found++; +console.log("cycles", found); diff --git a/tests/corpus/7902-versioned-loop-slow-copies.ts b/tests/corpus/7902-versioned-loop-slow-copies.ts new file mode 100644 index 000000000..91b769f47 --- /dev/null +++ b/tests/corpus/7902-versioned-loop-slow-copies.ts @@ -0,0 +1,139 @@ +// Loops whose versioning guard can fail, so they run the ordinary copy: +// arrays whose holes were filled later, counted loops that read past the +// end, arrays that change length inside the loop, typed-array windows past +// the end, fractional and NaN bounds, and callbacks that are replaced. The +// loops keep integer counters and sums that would also suit integer +// speculation, with values past 2^31 and 2^53, negative zero and fractions. + +function sum(values: number[]): string { + let total = 0; + let count = 0; + let seed = 12345; + let grow = 1; + for (let i = 0; i < values.length; i++) { + total += values[i]!; + count++; + seed = (seed * 1103515245 + 12345) % 2147483648; + grow = grow * 7 + i; + } + return `${total} ${count} ${seed} ${grow}`; +} + +function product(values: number[], limit: number): string { + let p = 1; + let steps = 0; + for (let i = 0; i < limit; i++) { + p *= values[i] ?? 1; + steps += 3; + } + return `${p} ${steps}`; +} + +function shrinking(values: number[]): string { + let total = 0; + for (let i = 0; i < values.length; i++) { + total += values[i]! * i; + if (values[i]! % 3 === 0) values.pop(); + } + return `${total} ${values.length}`; +} + +function windowSum(a: Int32Array, from: number, to: number): string { + let total = 0; + let mix = 1; + for (let i = from; i < to; i++) { + total += a[i] ?? -1; + mix = (mix * 31 + 17) % 65521; + } + return `${total} ${mix}`; +} + +function bytes(a: Uint8Array, start: number, n: number): string { + let hash = 0; + let steps = 0; + for (let i = start; i < start + n; i++) { + hash = (hash * 31 + (a[i] ?? 7)) | 0; + steps++; + } + return `${hash} ${steps}`; +} + +function counted(values: Float64Array, n: number, step: number): string { + let total = 0; + let odd = 0; + for (let i = 0; i < n; i += step) { + values[i] = i * 2; + total += i; + odd = odd * 2 + 1; + } + return `${total} ${odd} ${values.join()}`; +} + +function mapped(values: number[], f: (x: number) => number): string { + let total = 0; + let k = 0; + for (let i = 0; i < values.length; i++) { + total += f(values[i]!); + k = k * 10 + 1; + } + return `${total} ${k}`; +} + +function lastNegativeZero(values: number[]): string { + let last = 0; + let zero = 0; + for (let i = 0; i < values.length; i++) { + last = values[i]! * -1; + zero = zero * -1; + } + return `${Object.is(last, -0) ? "-0" : String(last)} ${Object.is(zero, -0) ? "-0" : zero}`; +} + +/** An array written out of order: index 2 first, then the hole at 1. */ +function filledLate(): number[] { + const values: number[] = [1]; + values[2] = 3; + values[1] = 2; + return values; +} + +console.log(sum([1, 2, 3, 4, 5])); +console.log(sum(filledLate())); +console.log(sum([1, 2.5, 3])); +console.log(sum([2147483647, 2147483647, 2147483647])); +console.log(sum([9007199254740991, 2, 2])); +console.log(sum(Array.from({ length: 40 }, (_, i) => (i % 2 === 0 ? i : -i)))); + +console.log(product([1, 2, 3, 4], 4)); +console.log(product([1, 2, 3, 4], 6)); +console.log(product([65536, 65536, 65536], 3)); +console.log(product([0, -1], 2), product([-1, 0], 2)); + +console.log(shrinking([1, 2, 3, 4, 5, 6, 7, 8, 9])); +console.log(shrinking([3, 3, 3, 3])); + +const ints = new Int32Array([10, 20, 30, 40]); +console.log(windowSum(ints, 0, 4), windowSum(ints, 2, 7), windowSum(ints, -2, 2)); +console.log(windowSum(ints, 0.5, 3.5), windowSum(ints, 0, NaN)); + +const text = new Uint8Array([104, 101, 108, 108, 111]); +console.log(bytes(text, 0, 5), bytes(text, 3, 5), bytes(text, -1, 3)); + +console.log(counted(new Float64Array(12), 10, 1), counted(new Float64Array(12), 10.5, 1)); +console.log(counted(new Float64Array(12), 10, 2.5), counted(new Float64Array(4), NaN, 1)); +console.log(counted(new Float64Array(4), 8, 1), counted(new Float64Array(4), 3, 0.75)); +console.log(counted(new Float64Array(60), 60, 1).split(" ").slice(0, 2).join(" ")); + +let calls = 0; +const double = (x: number): number => { + calls++; + return x * 2; +}; +const half = (x: number): number => { + calls++; + return x / 2; +}; +console.log(mapped([1, 2, 3], double), mapped([1, 2, 3], half), calls); +console.log(mapped(filledLate(), double), mapped([1, 2.5], (x) => x * x), calls); + +console.log(lastNegativeZero([1, 2, 0]), lastNegativeZero([1, 2, 3]), lastNegativeZero([])); diff --git a/tests/corpus/7983-integer-compares.ts b/tests/corpus/7983-integer-compares.ts new file mode 100644 index 000000000..2603a39a1 --- /dev/null +++ b/tests/corpus/7983-integer-compares.ts @@ -0,0 +1,166 @@ +// Compares of integer values that are not doubles in the generated code: +// typed-array elements and char codes, which read as NaN past the end, and +// sums such as `2 * i + 1` whose double is the integer rounded once, near and +// past 2^53. + +function countPairs(a: Int32Array): string { + // `j` runs one past the end, where `a[j]` is NaN and every order compare + // is false. + let lt = 0; + let ge = 0; + let eq = 0; + let ne = 0; + for (let i = 0; i < a.length; i++) { + for (let j = 0; j <= a.length; j++) { + if (a[i]! < a[j]!) lt++; + if (a[i]! >= a[j]!) ge++; + if (a[i]! === a[j]!) eq++; + if (a[i]! !== a[j]!) ne++; + } + } + return `${lt} ${ge} ${eq} ${ne}`; +} + +function pastTheEnd(a: Int32Array, b: Uint8Array, c: Uint32Array): string { + const n = a.length; + const out: boolean[] = []; + out.push(a[n]! < a[0]!, a[n]! > a[0]!, a[n]! <= a[n]!, a[n]! === a[0]!, a[0]! !== a[n]!); + out.push(a[n]! < 0, a[n]! >= -1, a[-1]! < a[0]!, b[n]! <= b[0]!, b[0]! !== b[n]!); + out.push(c[n]! < c[0]!, c[n]! >= c[n]!, c[0]! === c[0]!, c[n]! !== c[0]!); + out.push(a[0]! < b[n]!, b[0]! >= a[n]!, a[0]! < c[n]!); + return out.map((v) => (v ? 1 : 0)).join(""); +} + +function unsigned(c: Uint32Array): string { + // Elements above 2^31 compare as unsigned values. + let below = 0; + let above = 0; + let same = 0; + for (let i = 0; i < c.length; i++) { + for (let j = 0; j < c.length; j++) { + if (c[i]! < c[j]!) below++; + if (c[i]! > c[j]!) above++; + if (c[i]! === c[j]!) same++; + } + } + return `${below} ${above} ${same}`; +} + +function mixed(a: Int32Array, b: Uint8Array, c: Uint32Array, d: Int16Array): string { + let count = 0; + for (let i = 0; i < a.length; i++) { + if (a[i]! < b[i]!) count += 1; + if (a[i]! < c[i]!) count += 10; + if (c[i]! > d[i]!) count += 100; + if (b[i]! >= d[i]!) count += 1000; + if (a[i]! === c[i]!) count += 10000; + } + return String(count); +} + +function charCodes(s: string): string { + // `s.charCodeAt(s.length)` is NaN. + let rising = 0; + let falls = 0; + let equal = 0; + let differ = 0; + for (let i = 0; i < s.length; i++) { + if (s.charCodeAt(i) < s.charCodeAt(i + 1)) rising++; + if (s.charCodeAt(i) >= s.charCodeAt(i + 1)) falls++; + if (s.charCodeAt(i) === s.charCodeAt(i + 1)) equal++; + if (s.charCodeAt(i) !== s.charCodeAt(i + 1)) differ++; + } + return `${rising} ${falls} ${equal} ${differ}`; +} + +function heapChildren(start: number, end: number, steps: number): string { + // `2 * i + 1` passes 2^53 for `i` at or above 2^52, where its double + // rounds; `end` stays a safe integer. + const out: string[] = []; + for (let i = start; i < start + steps; i++) { + const child = 2 * i + 1; + out.push( + [2 * i + 1 < end, 2 * i + 1 <= end, 2 * i + 1 > end, 2 * i + 1 === end, 2 * i + 1 !== end] + .map((v) => (v ? 1 : 0)) + .join("") + ` ${child}`, + ); + } + return out.join(" "); +} + +function countNear(start: number, end: number, steps: number): string { + // The same compares in a loop over integer locals only, stopping early if + // `i` reaches `end`. + let lt = 0; + let le = 0; + let gt = 0; + let eq = 0; + let ne = 0; + for (let i = start; i < start + steps && i !== end; i++) { + if (2 * i + 1 < end) lt++; + if (2 * i + 1 <= end) le++; + if (2 * i + 1 > end) gt++; + if (2 * i + 1 === end) eq++; + if (2 * i + 1 !== end) ne++; + } + return `${lt} ${le} ${gt} ${eq} ${ne}`; +} + +function descend(root: number, end: number): number { + // A sift-down's index walk: doubles `root` until `2 * root + 1` reaches `end`. + let steps = 0; + while (2 * root + 1 < end) { + root = 2 * root + 1; + steps++; + } + return steps * 1000 + (root % 1000); +} + +function scaled(i: number, end: number): string { + // Products by a power of two never round: 4 * i and 1024 * i compare exactly. + return [4 * i < end, 4 * i === end, 1024 * i - 1 < end, 3 * i + 1 < end, 2 * i - 1 > -end] + .map((v) => (v ? 1 : 0)) + .join(""); +} + +function roundedProducts(): string { + // 3 * (2^52 - 1) is 13510798882111485, which rounds to 13510798882111484 = + // 4 * 3377699720527871: the products' doubles are equal and the integers + // are not. A product by 4 never rounds. + let equal = 0; + let below = 0; + for (let i = 1; i <= 8; i++) { + for (let j = 3377699720527869; j <= 3377699720527873; j++) { + if (i * 4503599627370495 === j * 4) equal++; + if (i * 4503599627370495 < j * 4) below++; + } + } + return `${equal} ${below}`; +} + +const a = new Int32Array([5, -3, 2147483647, -2147483648, 5, 0]); +const b = new Uint8Array([0, 255, 7, 128, 5, 0]); +const c = new Uint32Array([4294967295, 2147483648, 2147483647, 0, 1, 4294967295]); +const d = new Int16Array([-32768, 32767, -1, 1, 5, 0]); +console.log(countPairs(a)); +console.log(countPairs(new Int32Array(0))); +console.log(pastTheEnd(a, b, c)); +console.log(unsigned(c)); +console.log(mixed(a, b, c, d)); +console.log(charCodes("abcddcbaÿ￿\u0000")); +console.log(charCodes("")); +const p52 = 2 ** 52; +const safe = Number.MAX_SAFE_INTEGER; +console.log(heapChildren(p52 - 2, safe, 5)); +console.log(heapChildren(p52 - 2, safe - 1, 5)); +console.log(heapChildren(p52 + 100, safe, 3)); +console.log(heapChildren(-p52 - 2, -safe, 5)); +console.log(heapChildren(0, 4, 4)); +console.log(countNear(p52 - 3, safe, 6), countNear(p52 - 3, safe - 2, 6), countNear(p52, safe, 4)); +console.log(countNear(-p52 - 3, -safe, 6), countNear(-p52 + 1, -safe + 4, 4), countNear(0, 7, 6)); +console.log(descend(0, safe), descend(1, safe), descend(p52 - 1, safe), descend(p52, safe)); +console.log(descend(3, 100), descend(0, 1), descend(5, 0)); +for (const i of [0, 1, p52 / 2 - 1, p52 / 2, p52 / 1024, p52 / 2 + 1, p52, -p52]) { + console.log(scaled(i, safe), scaled(i, safe - 1), scaled(i, 2 ** 51), scaled(i, 4)); +} +console.log(roundedProducts()); diff --git a/tests/corpus/7984-untyped-array-methods.mjs b/tests/corpus/7984-untyped-array-methods.mjs new file mode 100644 index 000000000..a68ec305e --- /dev/null +++ b/tests/corpus/7984-untyped-array-methods.mjs @@ -0,0 +1,120 @@ +// Untyped calls of push, pop, shift, at, includes and indexOf: plain arrays +// take a direct path; arrays with holes, frozen, sealed or non-extensible +// arrays, Array.prototype, proxies, and arrays whose own property or +// prototype defines the method take the full dispatch. Each call is made +// both for its result and as a statement, whose result is dropped. +function attempt(label, f) { + try { + console.log(label, f()); + } catch (e) { + console.log(label, "threw", e.constructor.name, e.message); + } +} +function push(a, v) { + return a.push(v); +} +function pushOnly(a, v) { + a.push(v); + return a.length; +} +function push2(a, v, w) { + return a.push(v, w); +} +function pop(a) { + return a.pop(); +} +function shift(a) { + return a.shift(); +} +function at(a, i) { + return a.at(i); +} +function includes(a, v) { + return a.includes(v); +} +function indexOf(a, v) { + return a.indexOf(v); +} +function indexFrom(a, v, from) { + return a.indexOf(v, from); +} +function len(a) { + return a.length; +} + +const holes = JSON.parse("[1, 2, 3]"); +delete holes[1]; +attempt("holes push", () => push(holes, 4)); +attempt("holes push statement", () => pushOnly(holes, 5)); +attempt("holes includes", () => [includes(holes, undefined), indexOf(holes, undefined), at(holes, 1)]); +attempt("holes pop", () => [pop(holes), len(holes)]); +attempt("holes shift", () => [shift(holes), len(holes), 1 in holes]); + +const frozen = Object.freeze(JSON.parse("[1, 2]")); +attempt("frozen push", () => push(frozen, 3)); +attempt("frozen push statement", () => pushOnly(frozen, 3)); +attempt("frozen pop", () => pop(frozen)); +attempt("frozen shift", () => shift(frozen)); +attempt("frozen includes", () => [includes(frozen, 2), indexOf(frozen, 2), at(frozen, -1)]); + +const sealed = Object.seal(JSON.parse("[1, 2]")); +attempt("sealed push", () => push(sealed, 3)); +attempt("sealed push statement", () => pushOnly(sealed, 3)); +attempt("sealed pop", () => pop(sealed)); +attempt("sealed shift", () => [shift(sealed), JSON.stringify(sealed)]); +attempt("sealed includes", () => [includes(sealed, 2), at(sealed, 0)]); + +const fixed = Object.preventExtensions(JSON.parse("[1, 2]")); +attempt("fixed push", () => push(fixed, 3)); +attempt("fixed push statement", () => pushOnly(fixed, 3)); +attempt("fixed pop", () => [pop(fixed), len(fixed)]); +attempt("fixed shift", () => [shift(fixed), len(fixed)]); + +attempt("prototype includes", () => [includes(Array.prototype, 1), indexOf(Array.prototype, 1), at(Array.prototype, 0)]); + +const handler = new Proxy({ push(v) { return "proxied push " + v; }, at(i) { return "proxied at " + i; } }, {}); +attempt("proxy push", () => push(handler, 7)); +attempt("proxy push statement", () => pushOnly(handler, 7)); +attempt("proxy at", () => at(handler, 1)); + +const own = JSON.parse("[1, 2]"); +own.push = function (v) { + return "own push " + v; +}; +own.includes = function (v) { + return "own includes " + v; +}; +attempt("own push", () => [push(own, 9), len(own)]); +attempt("own push statement", () => pushOnly(own, 9)); +attempt("own includes", () => [includes(own, 1), indexOf(own, 1)]); +const notCallable = JSON.parse("[1, 2]"); +notCallable.pop = 5; +attempt("own non-callable pop", () => pop(notCallable)); + +const customProto = Object.create(Array.prototype); +customProto.push = function (v) { + return "proto push " + v + " onto " + this.length; +}; +const reproto = JSON.parse("[1, 2]"); +Object.setPrototypeOf(reproto, customProto); +attempt("reproto push", () => push(reproto, 9)); +attempt("reproto push statement", () => pushOnly(reproto, 9)); +attempt("reproto pop", () => [pop(reproto), len(reproto)]); + +const plain = JSON.parse("[]"); +for (let i = 0; i < 5; i++) push2(plain, i, i * 2); +for (let i = 0; i < 3; i++) pushOnly(plain, "s" + i); +attempt("plain", () => [len(plain), pop(plain), shift(plain), at(plain, -2), at(plain, 1.9), at(plain, -0), at(plain, NaN), at(plain, 100), at(plain, -100)]); +attempt("plain search", () => [includes(plain, 8), indexOf(plain, 8), includes(plain, "s1"), indexOf(plain, "s1"), indexOf(plain, "s9"), indexFrom(plain, 2, 3), includes(plain)]); +attempt("plain at string", () => [at(plain, "1"), at(plain, undefined), at(plain, { valueOf() { return 2; } })]); +const nans = JSON.parse("[1]"); +pushOnly(nans, NaN); +pushOnly(nans, -0); +attempt("nan", () => [includes(nans, NaN), indexOf(nans, NaN), includes(nans, 0), indexOf(nans, 0), indexOf(nans, -0)]); +const empty = JSON.parse("[]"); +attempt("empty", () => [pop(empty), shift(empty), at(empty, 0), includes(empty, undefined), indexOf(empty, undefined), len(empty)]); +const objects = JSON.parse("[]"); +const key = { k: 1 }; +pushOnly(objects, key); +pushOnly(objects, { k: 1 }); +attempt("objects", () => [includes(objects, key), indexOf(objects, key), indexOf(objects, { k: 1 }), at(objects, 0) === key]); diff --git a/tests/corpus/7993-json-parse-error-messages.ts b/tests/corpus/7993-json-parse-error-messages.ts new file mode 100644 index 000000000..76605e501 --- /dev/null +++ b/tests/corpus/7993-json-parse-error-messages.ts @@ -0,0 +1,30 @@ +// JSON.parse syntax error messages, exactly as Node's V8 writes them: the +// expected token at the end of the input inside objects and arrays, the +// position as a UTF-16 offset with its line and column (\n, \r and \r\n +// end lines), the unexpected character named with the source (whole when +// short, ten units of context on each side when longer), and the special +// sources that name only themselves. +const inputs: string[] = [ + "", " ", "[", "[{", "{", '{"a"', '{"a":', '{"a":1', '{"a":1,', '{"a":1,}', "{,", "{1:2}", "{'a':1}", + '{"a" 1}', '{"a":1 "b":2}', "[1", "[1,", "[1,]", "[1 2]", "[,1]", '"abc', '"a\\', '"\\u12', '"\\u12x4"', + '"\\u12"', '"\\x"', "tru", "nul", "fals", "t1", 'n"', "-", "-x", "1.", "1.e", "1e", "1e+", "01", "-01", + "[01]", "+1", "1 2", "{} x", "[]]", "\u0001", '"\u0001"', '{"a":[1,{"b":', '[{"a":1},', '{"a":{"b":{}', + "[[[", " [ \n { \n", "\n\n{", "\r\n\r\n{", "\r\r{", '{"a":tru}', "NaN", "Infinity", "undefined", + "[object Object]", " NaN", "-Infinity", "[-]", + // Positions count UTF-16 units: non-ASCII text before the error. + '{"é":1 x}', '["😀" 1]', '{"😀😀":', '"é\u0001"', "é", + // Long sources: the unexpected character with its context. + '{"name":"some long value","other":x}', '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,]', + 'x{"name":"some long value","other":1}', '{"name":"some long value","other":1}x', '{"a":"0123456789abcdef"x}', + '["😀😀😀😀😀😀", x, "😀😀😀😀😀😀"]', '["😀😀😀😀😀😀",x]', '{"k":[true,false,null,"s",1.5e3,-2] ]', + '\n\n {\n "a": [1, 2,\n 3 4]\n }', +]; +for (const text of inputs) { + try { + JSON.parse(text); + console.log(JSON.stringify(text), "parsed"); + } catch (e) { + const err = e as Error; + console.log(JSON.stringify(text), err.name, JSON.stringify(err.message)); + } +} diff --git a/tests/corpus/8002-while-byte-length-companions.ts b/tests/corpus/8002-while-byte-length-companions.ts new file mode 100644 index 000000000..e37ec9e57 --- /dev/null +++ b/tests/corpus/8002-while-byte-length-companions.ts @@ -0,0 +1,201 @@ +// While loops bounded by a typed array or Buffer length, with companion +// counters that only grow: reversed copies with line breaks, steps of two, +// early exits, skipped passes, nested loops and loops inside loops. + +function reverseLines( + line: Uint8Array, + target: Uint8Array, + start: number, +): number { + const len = line.length; + const right = len - 1; + let read = 0; + let written = 0; + let count = 0; + let lines = 0; + while (read < len) { + let c = line[right - read]!; + read++; + if (c === 10) { + if (read === len) break; + c = line[right - read]!; + read++; + } + target[written] = c + 1; + written++; + count++; + if ((count + start - lines) % 5 === 0) { + target[written] = 10; + written++; + count++; + lines++; + } + } + return written * 1000 + lines * 10 + count; +} + +const text = Buffer.from("ABCDEFGHIJ\nKLMNOPQRST\nUVWXYZ\n"); +const out = new Uint8Array(64); +for (const start of [0, 1, 3, 7]) { + const summary = reverseLines(text, out, start); + console.log( + start, + summary, + Buffer.from(out.subarray(0, Math.floor(summary / 1000))).toString("latin1"), + ); +} + +function pairs(values: Uint16Array): number[] { + let i = 0; + let sums = 0; + let odd = 0; + const result: number[] = []; + while (i < values.length) { + i += 2; + sums++; + if ((values[i - 2]! & 1) === 1) odd += 3; + result.push(values[i - 2]! + (values[i - 1] ?? -1)); + } + result.push(i, sums, odd); + return result; +} +console.log(pairs(new Uint16Array([1, 2, 3, 4, 5]))); +console.log(pairs(new Uint16Array(0))); +console.log(pairs(new Uint16Array([65535, 65535]))); + +function firstZero(bytes: Uint8Array): string { + let i = 0; + let seen = 0; + let total = -5; + while (i < bytes.length) { + i++; + if (bytes[i - 1] === 0) break; + seen++; + total += bytes[i - 1]!; + } + return `${i} ${seen} ${total}`; +} +console.log( + firstZero(new Uint8Array([3, 4, 0, 9])), + firstZero(new Uint8Array([1, 2])), + firstZero(new Uint8Array(0)), +); + +function skipping(bytes: Uint8Array): string { + let i = 0; + let kept = 0; + let skipped = 0; + while (i < bytes.length) { + const b = bytes[i]!; + i++; + if (b > 100) { + skipped++; + continue; + } + kept++; + } + return `${i} ${kept} ${skipped}`; +} +console.log(skipping(new Uint8Array([1, 200, 3, 250, 255, 7]))); + +function repeated(bytes: Uint8Array): string { + let i = 0; + let inner = 0; + let outer = 0; + while (i < bytes.length) { + i++; + outer++; + let j = 0; + while (j < bytes[i - 1]!) { + j++; + inner++; + } + } + return `${i} ${outer} ${inner}`; +} +console.log(repeated(new Uint8Array([2, 0, 5, 1]))); + +function grid(rows: Float64Array[]): string { + let total = 0; + let cells = 0; + for (const row of rows) { + let k = 0; + let doubled = 0; + while (k < row.length) { + k++; + cells++; + doubled += 2; + total += row[k - 1]! * doubled; + } + total += k * 0.5; + } + return `${cells} ${total}`; +} +console.log( + grid([ + new Float64Array([0.5, 1.5]), + new Float64Array(0), + new Float64Array([-2, 3, 4.25]), + ]), +); + +function views(buffer: ArrayBuffer): string { + const view = new DataView(buffer, 2); + let offset = 0; + let words = 0; + const parts: string[] = []; + while (offset < view.byteLength) { + offset += 2; + words++; + if (offset <= view.byteLength) + parts.push(view.getUint16(offset - 2, true).toString(16)); + } + return `${offset} ${words} ${parts.join(",")}`; +} +const backing = new Uint8Array([9, 9, 1, 2, 3, 4, 5]).buffer; +console.log(views(backing)); + +function captured(bytes: Uint8Array): number { + let i = 0; + let n = 0; + const read = (): number => n; + while (i < bytes.length) { + i++; + n++; + } + return read() + i; +} +console.log(captured(new Uint8Array(4))); + +function fromNegative(bytes: Int8Array): string { + let i = 0; + let level = -3; + let low = -0; + while (i < bytes.length) { + i++; + level++; + if (bytes[i - 1]! < 0) low++; + } + return `${level} ${low} ${Object.is(low, -0)}`; +} +console.log( + fromNegative(new Int8Array([-1, 2, -3])), + fromNegative(new Int8Array(0)), +); + +function throwing(bytes: Uint8Array): number { + let i = 0; + let n = 0; + while (i < bytes.length) { + i++; + n++; + if (bytes[i - 1] === 7) throw new Error(`stopped at ${i} after ${n}`); + } + return n; +} +try { + console.log(throwing(new Uint8Array([1, 2]))); + console.log(throwing(new Uint8Array([1, 7, 3]))); +} catch (error) { + console.log((error as Error).message); +} diff --git a/tests/corpus/8020-date-setters.ts b/tests/corpus/8020-date-setters.ts new file mode 100644 index 000000000..6bfeff5c8 --- /dev/null +++ b/tests/corpus/8020-date-setters.ts @@ -0,0 +1,115 @@ +// Date setters: every local and UTC setter with each argument count, the +// time values they return, normalization of out-of-range fields, local +// setters across a daylight saving transition, setters on invalid dates, +// explicit undefined arguments, fractional arguments, and the TimeClip +// range. A setter changes the one Date object every alias holds. +export {}; +process.env.TZ = "America/Chicago"; + +const show = (label: string, d: Date): void => { + console.log(label, d.getTime(), Number.isNaN(d.getTime()) ? "invalid" : d.toISOString()); +}; + +const d = new Date(Date.UTC(2026, 0, 31, 10, 20, 30, 400)); +console.log(d.setMilliseconds(999), d.getMilliseconds()); +console.log(d.setSeconds(5), d.setSeconds(6, 7)); +console.log(d.setMinutes(1), d.setMinutes(2, 3), d.setMinutes(4, 5, 6)); +console.log(d.setHours(7), d.setHours(8, 9), d.setHours(10, 11, 12), d.setHours(13, 14, 15, 16)); +console.log(d.setDate(15), d.setMonth(1), d.setMonth(2, 3)); +console.log(d.setFullYear(2025), d.setFullYear(2024, 1), d.setFullYear(2023, 4, 6)); +show("local", d); + +const u = new Date(Date.UTC(2026, 0, 31)); +console.log(u.setUTCMilliseconds(1), u.setUTCSeconds(2, 3), u.setUTCMinutes(4, 5, 6)); +console.log(u.setUTCHours(7, 8, 9, 10), u.setUTCDate(1), u.setUTCMonth(5, 30)); +console.log(u.setUTCFullYear(1999), u.setUTCFullYear(2000, 1, 29)); +show("utc", u); + +// Fields out of range carry into the next larger field, both ways. +const n = new Date(Date.UTC(2026, 0, 31, 12)); +n.setUTCMonth(1); +show("Jan 31 + month", n); +n.setUTCDate(0); +show("day 0", n); +n.setUTCMonth(13); +show("month 13", n); +n.setUTCHours(-1); +show("hour -1", n); +n.setUTCMinutes(60 * 24 + 1); +show("minutes past a day", n); +n.setUTCSeconds(-0.5); +show("fractional seconds", n); +n.setUTCMilliseconds(1.9); +show("fractional ms", n); + +// Local setters in a zone with daylight saving time: 2026-03-08 02:30 +// does not exist in Chicago and 2026-11-01 01:30 happens twice. +const spring = new Date(2026, 2, 8, 0, 0); +spring.setHours(2, 30); +show("spring gap", spring); +console.log(spring.getHours(), spring.getMinutes(), spring.getTimezoneOffset()); +const fall = new Date(2026, 10, 1, 0, 0); +fall.setHours(1, 30); +show("fall overlap", fall); +fall.setMinutes(fall.getMinutes() + 60); +show("fall overlap + 60 min", fall); +const walk = new Date(2026, 2, 7, 12); +for (let i = 0; i < 3; i++) { + walk.setDate(walk.getDate() + 1); + console.log("walk", walk.getDate(), walk.getHours(), walk.getTimezoneOffset()); +} + +// Invalid dates: setFullYear starts from +0 local time, the others keep NaN. +const bad = new Date(NaN); +console.log(bad.setHours(1), bad.setDate(2), bad.setUTCMonth(3), bad.setMilliseconds(4)); +console.log(bad.setFullYear(2020)); +show("after setFullYear", bad); +const badUtc = new Date("not a date"); +console.log(badUtc.setUTCFullYear(2021, 6)); +show("after setUTCFullYear", badUtc); + +// Explicit undefined and NaN arguments make the date invalid; a missing +// optional argument keeps the current field. +const opt = new Date(Date.UTC(2026, 5, 15, 9)); +console.log(opt.setUTCHours(10, undefined), Number.isNaN(opt.getTime())); +const opt2 = new Date(Date.UTC(2026, 5, 15, 9)); +console.log(opt2.setUTCMonth(Number.NaN), opt2.setUTCMonth(2)); + +// setTime and the TimeClip range. +const t = new Date(0); +console.log(t.setTime(8.64e15), t.setTime(8.64e15 + 1), t.setTime(-8.64e15), t.setTime(1.5)); +console.log(t.setTime(-0), Object.is(t.getTime(), 0), t.setTime(Number.NaN)); +const far = new Date(Date.UTC(275760, 8, 13)); +console.log(far.getTime(), far.setUTCMilliseconds(1)); + +// Every alias sees the change: a second binding, an array slot, a Map +// value, a record field, a closure and a parameter. +const shared = new Date(Date.UTC(2026, 6, 4)); +const alias = shared; +const list = [shared, new Date(Date.UTC(2000, 0, 1))]; +const byName = new Map([["shared", shared]]); +const holder = { when: shared }; +const bump = (): number => shared.setUTCDate(shared.getUTCDate() + 1); +const nextYear = (date: Date): void => { + date.setUTCFullYear(date.getUTCFullYear() + 1); +}; +alias.setUTCHours(6); +list[0]!.setUTCMinutes(30); +byName.get("shared")!.setUTCSeconds(15); +holder.when.setUTCMilliseconds(250); +bump(); +nextYear(shared); +show("shared", shared); +console.log(alias === shared, list[0] === shared, byName.get("shared") === holder.when); +console.log(list.map((x) => x.toISOString()).join(" ")); + +// A Date a loop adjusts in place. +const cursor = new Date(Date.UTC(2026, 0, 1)); +let weekdays = 0; +while (cursor.getUTCFullYear() === 2026) { + const day = cursor.getUTCDay(); + if (day !== 0 && day !== 6) weekdays++; + cursor.setUTCDate(cursor.getUTCDate() + 1); +} +console.log("weekdays in 2026", weekdays); +show("cursor", cursor); diff --git a/tests/corpus/8021-date-coercions.ts b/tests/corpus/8021-date-coercions.ts new file mode 100644 index 000000000..2410853a4 --- /dev/null +++ b/tests/corpus/8021-date-coercions.ts @@ -0,0 +1,47 @@ +// Dates converted to primitives as Node converts them: unary +, Number() +// and relational operators read the time value (the number hint), while + +// with a string, template literals and String() read the text (a Date's +// default hint is a string). Symbol.toPrimitive, valueOf and toString answer +// the same values; == compares two Dates by identity and a Date with a +// primitive through its default primitive. typeof, truthiness and +// instanceof treat a Date as an object, invalid ones included. +export {}; +process.env.TZ = "UTC"; + +const a = new Date(Date.UTC(2026, 9, 23, 8, 30)); +const b = new Date(Date.UTC(2026, 9, 24, 8, 30)); +const bad = new Date(Number.NaN); + +console.log(+a, -a, +b - +a, (+b - +a) / 3600000, Number(a), Math.max(+a, +b) === b.getTime()); +console.log(a + "!"); +console.log(`at ${a}`); +console.log(String(b)); +console.log("" + a === a.toString(), a + "1" === a.toString() + "1"); +console.log(a[Symbol.toPrimitive]("number"), a[Symbol.toPrimitive]("string") === a.toString()); +console.log(a[Symbol.toPrimitive]("default") === a.toString(), a.valueOf() === a.getTime()); +console.log(a < b, a <= b, a > b, a >= b, +b > a.getTime() + 1); + +const same = a; +const twin = new Date(a.getTime()); +console.log(a == same, a == twin, a === twin, a != twin, a.getTime() === twin.getTime()); +console.log(a == (a.toString() as unknown), a == (a.getTime() as unknown)); + +console.log(typeof a, typeof bad, a instanceof Date, bad instanceof Date); +console.log(Object.prototype.toString.call(a), !a, !!bad, bad ? "truthy" : "falsy"); + +// Invalid dates. +console.log(+bad, +bad - +a, `${bad}`, String(bad), bad + "", Number.isNaN(bad.getTime())); +console.log(bad.toJSON(), JSON.stringify({ when: bad, at: a })); +try { + bad.toISOString(); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} + +// Sorting and summing through the number hint. +const days = [b, a, new Date(Date.UTC(2026, 0, 1))]; +days.sort((x, y) => +x - +y); +console.log(days.map((x) => x.getUTCDate()).join(",")); +let span = 0; +for (let i = 1; i < days.length; i++) span += +days[i]! - +days[i - 1]!; +console.log(span / 86400000); diff --git a/tests/corpus/8022-date-unions.ts b/tests/corpus/8022-date-unions.ts new file mode 100644 index 000000000..dedcd8bf1 --- /dev/null +++ b/tests/corpus/8022-date-unions.ts @@ -0,0 +1,68 @@ +// Dates in unions with other arms: Date | string, Date | number and +// Date | a record, narrowed with instanceof and typeof, in arrays, Map +// values, optional fields and function results. A Date arm keeps its +// identity, so a setter through a union-typed alias changes the original. +export {}; +process.env.TZ = "UTC"; + +type When = Date | string | number; + +function describe(when: When): string { + if (when instanceof Date) return `date ${when.toISOString()}`; + if (typeof when === "string") return `text ${when}`; + return `ms ${when}`; +} + +function normalize(when: When): Date { + return when instanceof Date ? when : new Date(when); +} + +const fixed = new Date(Date.UTC(2026, 9, 23)); +const values: When[] = [fixed, "2026-01-02T03:04:05Z", 0, new Date(Number.NaN)]; +for (const value of values) { + const text = value instanceof Date && Number.isNaN(value.getTime()) ? "invalid" : describe(value); + console.log(text); +} +console.log(normalize(fixed) === fixed, normalize("2000-01-01").getUTCFullYear(), normalize(1e12).getUTCFullYear()); + +const held: When = fixed; +if (held instanceof Date) held.setUTCHours(12); +console.log(fixed.getUTCHours(), held === fixed); + +type Stamp = Date | { label: string; at: number }; +const stamps: Stamp[] = [fixed, { label: "start", at: 5 }]; +for (const stamp of stamps) { + console.log(stamp instanceof Date ? stamp.getUTCDate() : `${stamp.label}@${stamp.at}`); +} + +const byKey = new Map(); +byKey.set("a", fixed); +byKey.set("b", "later"); +for (const [key, value] of byKey) console.log(key, value instanceof Date ? value.getTime() : value); + +interface Task { + name: string; + due?: Date; + done: Date | null; +} +const tasks: Task[] = [ + { name: "write", due: fixed, done: null }, + { name: "ship", done: new Date(Date.UTC(2026, 11, 1)) }, +]; +for (const task of tasks) { + const due = task.due ? task.due.toISOString().slice(0, 10) : "none"; + const done = task.done === null ? "open" : task.done.toISOString().slice(0, 10); + console.log(task.name, due, done); +} +tasks[0]!.due!.setUTCDate(30); +console.log(fixed.getUTCDate(), tasks[0]!.due === fixed); + +function latest(dates: (Date | undefined)[]): Date | string { + let best: Date | undefined; + for (const date of dates) if (date && (best === undefined || date > best)) best = date; + return best ?? "none"; +} +const found = latest([undefined, fixed, new Date(0)]); +console.log(found === fixed, latest([])); + +console.log(JSON.stringify({ values, stamps })); diff --git a/tests/corpus/8023-date-clone-throw.ts b/tests/corpus/8023-date-clone-throw.ts new file mode 100644 index 000000000..b9d569d00 --- /dev/null +++ b/tests/corpus/8023-date-clone-throw.ts @@ -0,0 +1,32 @@ +// Dates through structuredClone and MessageChannel, which copy them as +// new Dates with the same time value, and Dates thrown and rejected as +// values, which keep their identity. +import { MessageChannel } from "node:worker_threads"; +process.env.TZ = "UTC"; + +const at = new Date(Date.UTC(2026, 9, 23, 7)); +const copy = structuredClone(at); +console.log(copy instanceof Date, copy === at, copy.getTime() === at.getTime()); +copy.setUTCFullYear(2000); +console.log(at.getUTCFullYear(), copy.getUTCFullYear()); + +const nested = structuredClone({ at, list: [at, at], invalid: new Date(Number.NaN) }); +console.log(nested.list[0] instanceof Date, nested.at === at, nested.list.length); +console.log(nested.at.toISOString(), Number.isNaN(nested.invalid.getTime())); + +try { + throw at; +} catch (error) { + console.log(error instanceof Date, error === at); +} + +const { port1, port2 } = new MessageChannel(); +port2.on("message", (data: { when: Date; again: Date }) => { + const { when, again } = data; + console.log("message", when instanceof Date, when === at, again instanceof Date, when.toISOString()); + port2.close(); + Promise.reject(at).catch((reason: unknown) => { + console.log("rejected", reason instanceof Date, reason === at); + }); +}); +port1.postMessage({ when: at, again: at }); diff --git a/tests/corpus/8024-date-setters-dynamic.mjs b/tests/corpus/8024-date-setters-dynamic.mjs new file mode 100644 index 000000000..9b6392b60 --- /dev/null +++ b/tests/corpus/8024-date-setters-dynamic.mjs @@ -0,0 +1,24 @@ +// Date setters in JavaScript, on Dates held as untyped values: each setter +// converts its arguments with ToNumber (valueOf runs, undefined is NaN) and +// changes the one Date object every alias holds. +process.env.TZ = "Europe/Berlin"; + +const d = new Date(Date.UTC(2026, 2, 28, 12)); +const alias = d; +const box = { d }; +console.log(d.setHours(1, 30), d.getHours(), d.getTimezoneOffset()); +console.log(alias.setDate(29), box.d.getHours(), d.getTimezoneOffset()); +console.log(d.setMinutes("15"), d.setSeconds(true), d.setMilliseconds(null)); +console.log(d.setUTCFullYear(2030, 11, 31), d.toISOString()); +console.log(d.setMonth(undefined), Number.isNaN(d.getTime())); +console.log(d.setFullYear(2001), d.toISOString()); + +let calls = 0; +const later = { valueOf() { calls++; return 45; } }; +console.log(d.setUTCMinutes(later, later), calls, d.toISOString()); + +const other = new Date(0); +console.log(other.setHours(5), other.getHours(), typeof d.setMonth); +const list = [new Date(0), new Date(86400000)]; +list.forEach((x, i) => x.setUTCHours(i + 1)); +console.log(list.map((x) => x.toISOString()).join(" ")); diff --git a/tests/corpus/8025-date-subclass-setters.ts b/tests/corpus/8025-date-subclass-setters.ts new file mode 100644 index 000000000..a6414a047 --- /dev/null +++ b/tests/corpus/8025-date-subclass-setters.ts @@ -0,0 +1,30 @@ +// Setters on instances of a class declared `extends Date`: each changes +// the instance's time value, which the inherited getters, the subclass's +// own methods and toISOString then read; invalid instances follow the same +// rules as Dates. +export {}; +process.env.TZ = "Asia/Kolkata"; + +class Deadline extends Date { + label: string; + constructor(label: string, ms: number) { + super(ms); + this.label = label; + } + extend(days: number): this { + this.setDate(this.getDate() + days); + return this; + } + describe(): string { + return `${this.label} ${this.toISOString()}`; + } +} + +const due = new Deadline("report", Date.UTC(2026, 9, 23, 18, 30)); +console.log(due.describe()); +console.log(due.setHours(9, 15), due.getHours(), due.getMinutes()); +console.log(due.extend(10).describe()); +console.log(due.setUTCMonth(0, 31), due.setMonth(1), due.getDate(), due.getMonth()); +console.log(due.setFullYear(2024, 1, 29), due.describe()); +console.log(due.setTime(Number.NaN), Number.isNaN(due.getTime()), due.setUTCFullYear(2030)); +console.log(due.describe(), due instanceof Date, due instanceof Deadline); diff --git a/tests/corpus/8026-date-subclass-setters-dynamic.mjs b/tests/corpus/8026-date-subclass-setters-dynamic.mjs new file mode 100644 index 000000000..8b89d6ec8 --- /dev/null +++ b/tests/corpus/8026-date-subclass-setters-dynamic.mjs @@ -0,0 +1,17 @@ +// Setters on instances of a JavaScript class declared `extends Date`, held +// as untyped values: the inherited setters change the instance's time value. +process.env.TZ = "America/Sao_Paulo"; + +class Shift extends Date { + start() { + this.setHours(8, 0, 0, 0); + return this; + } +} + +const shift = new Shift(Date.UTC(2026, 4, 5, 15)); +console.log(shift.start().toISOString(), shift.getHours()); +console.log(shift.setDate(31), shift.getMonth(), shift.setUTCHours(0), shift.toISOString()); +const values = [shift, new Shift(0)]; +for (const value of values) value.setUTCMinutes(45); +console.log(values.map((value) => value.toISOString()).join(" "), values[0] === shift); diff --git a/tests/corpus/8027-date-setter-order.ts b/tests/corpus/8027-date-setter-order.ts new file mode 100644 index 000000000..b87f3a656 --- /dev/null +++ b/tests/corpus/8027-date-setter-order.ts @@ -0,0 +1,66 @@ +// Date setters and evaluation order: a setter called without arguments +// makes the Date invalid, an argument that calls setTime on the same +// Date subclass instance is seen by the setter, a comparison or +// concatenation reads its left Date after the right side runs a setter, +// and toJSON reads its Date after an argument runs a setter. +export {}; +process.env.TZ = "UTC"; + +const show = (label: string, d: Date): void => { + console.log(label, d.getTime(), Number.isNaN(d.getTime()) ? "invalid" : d.toISOString()); +}; + +// No arguments: the first argument is undefined, so NaN. +const local = new Date(Date.UTC(2026, 5, 15, 9, 30)); +// @ts-expect-error: the first argument is required +console.log(local.setHours(), Number.isNaN(local.getTime())); +const utc = new Date(Date.UTC(2026, 5, 15, 9, 30)); +// @ts-expect-error: the first argument is required +console.log(utc.setUTCMonth(), utc.setUTCFullYear(2020)); +show("utc after", utc); +const kept = new Date(Date.UTC(2026, 5, 15)); +const keep = kept; +// @ts-expect-error: the first argument is required +console.log(keep.setMinutes(), kept.getTime()); + +class Stamp extends Date { + label(): string { + return `stamp ${this.toISOString()}`; + } +} +const stamp = new Stamp(Date.UTC(2026, 0, 1)); +// @ts-expect-error: the first argument is required +console.log(stamp.setSeconds(), Number.isNaN(stamp.getTime())); +stamp.setTime(Date.UTC(2026, 0, 2)); +// @ts-expect-error: the first argument is required +console.log(stamp.setUTCDate(), stamp.setUTCFullYear(2024, 1, 29), stamp.label()); + +// An argument that calls setTime on the same instance. +const moved = new Stamp(Date.UTC(2026, 3, 10, 12)); +console.log(moved.setUTCHours((moved.setTime(Date.UTC(2000, 0, 1)), 5)), moved.label()); +const plain = new Date(Date.UTC(2026, 3, 10, 12)); +console.log(plain.setUTCMinutes((plain.setTime(Date.UTC(2001, 0, 1)), 7))); +show("plain", plain); + +// The left operand converts after the right operand runs a setter. +const a = new Date(Date.UTC(2026, 0, 1)); +const b = new Date(Date.UTC(2026, 0, 2)); +console.log(a < (a.setUTCDate(3), b), a > b); +const c = new Date(Date.UTC(2026, 0, 1)); +console.log(c >= (c.setTime(0), new Date(1)), c <= new Date(1)); +const s = new Date(Date.UTC(2026, 0, 1)); +console.log(s + (s.setUTCFullYear(1999), "!")); +const n = new Date(Date.UTC(2026, 0, 1)); +console.log(+n === (n.setTime(5), 5), +n); + +// Locals a function keeps as their time value: a method reads the time +// value after its arguments run a setter. +function locals(): void { + const json = new Date(Date.UTC(2026, 0, 1)); + console.log(json.toJSON(json.setUTCFullYear(1999)), json.toJSON()); + const key = new Date(Date.UTC(2026, 0, 1)); + console.log(key.toJSON(String(key.setTime(Date.UTC(2021, 5, 1))))); + const read = new Date(Date.UTC(2026, 0, 1)); + console.log(read.toJSON(), read.getTime()); +} +locals(); diff --git a/tests/corpus/8028-date-setter-order-dynamic.mjs b/tests/corpus/8028-date-setter-order-dynamic.mjs new file mode 100644 index 000000000..f52e410c1 --- /dev/null +++ b/tests/corpus/8028-date-setter-order-dynamic.mjs @@ -0,0 +1,17 @@ +// Date setters reached through untyped values: without arguments each +// one makes the Date invalid, and an argument that calls setTime on the +// same Date is seen by the setter. +process.env.TZ = "UTC"; + +const names = [ + "setMilliseconds", "setSeconds", "setMinutes", "setHours", "setDate", "setMonth", "setFullYear", + "setUTCMilliseconds", "setUTCSeconds", "setUTCMinutes", "setUTCHours", "setUTCDate", "setUTCMonth", "setUTCFullYear", +]; +for (const name of names) { + const d = new Date(Date.UTC(2026, 5, 15)); + console.log(name, d[name](), d.getTime()); +} +const d = new Date(Date.UTC(2026, 3, 10, 12)); +console.log(d.setUTCHours((d.setTime(Date.UTC(2000, 0, 1)), 5)), d.toISOString()); +const a = new Date(Date.UTC(2026, 0, 1)); +console.log(a < (a.setUTCDate(3), new Date(Date.UTC(2026, 0, 2)))); diff --git a/tests/corpus/8062-assert-message-values.cjs b/tests/corpus/8062-assert-message-values.cjs new file mode 100644 index 000000000..fc37a71e4 --- /dev/null +++ b/tests/corpus/8062-assert-message-values.cjs @@ -0,0 +1,48 @@ +'use strict'; +// node:assert messages that are not string literals: an untyped string, an +// Error to throw, null/undefined (omitted) and falsy values, and +// scalar-union operands against untyped values. +const assert = require('assert'); + +function attempt(label, fn) { + try { + fn(); + console.log(label, 'passed'); + } catch (e) { + console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.code); + } +} + +const failures = []; +for (const x of ['a', 'b']) if (x === 'c') failures.push(`\n${x}`); +attempt('joined passes', () => assert.strictEqual(failures.length, 0, failures.join(''))); +const untyped = JSON.parse('"untyped message"'); +attempt('untyped', () => assert.strictEqual(1, 2, untyped)); +attempt('untyped deep', () => assert.deepStrictEqual([1], [2], untyped)); +attempt('untyped notStrict', () => assert.notStrictEqual(1, 1, untyped)); +attempt('error message', () => assert.strictEqual(1, 2, new TypeError('own error'))); +attempt('error deep', () => assert.deepStrictEqual({ a: 1 }, { a: 2 }, new RangeError('own range'))); +const none = JSON.parse('null'); +attempt('null message', () => assert.strictEqual(1, 2, none)); +const zero = JSON.parse('0'); +attempt('zero message deep', () => assert.deepStrictEqual([1], [2], zero)); +attempt('passing error message', () => assert.strictEqual(1, 1, new Error('unused'))); +attempt('later string message', () => assert.strictEqual(1, 2, 'plain')); + +// Operand order: both operands evaluate before the message. +const order = []; +const mark = (label, value) => (order.push(label), value); +attempt('order', () => assert.strictEqual(mark('a', 1), mark('b', 2), mark('m', JSON.parse('"m"')))); +console.log(order.join(',')); + +const row = [['a', 'b', true], ['c', 'd', 'x']]; +for (const [, , expected] of row) { + attempt('union operand', () => assert.strictEqual(JSON.parse(JSON.stringify(expected)), expected)); +} + +// assert.fail with a message that is not a string: an Error is thrown as +// is; other values become the message, generated when falsy. +attempt('fail error', () => assert.fail(new RangeError('re'))); +attempt('fail value', () => assert.fail(JSON.parse('{"a":1}'))); +attempt('fail zero', () => assert.fail(zero)); +attempt('fail null', () => assert.fail(JSON.parse('null'))); diff --git a/tests/corpus/8065-cjs-top-level-return/early.js b/tests/corpus/8065-cjs-top-level-return/early.js new file mode 100644 index 000000000..8d7de4ba3 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/early.js @@ -0,0 +1,6 @@ +'use strict'; +exports.ready = true; +console.log('early module start', typeof helper); +if (exports.ready) return; +console.log('early module end'); +function helper() {} diff --git a/tests/corpus/8065-cjs-top-level-return/main.js b/tests/corpus/8065-cjs-top-level-return/main.js new file mode 100644 index 000000000..7743eaac5 --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/main.js @@ -0,0 +1,16 @@ +'use strict'; +// A CommonJS module may end its evaluation with a top-level return: the +// rest of the module does not run, while timers, exit handlers and the +// modules it already required keep their effects. +const early = require('./early.js'); +console.log('early exports', early.ready); +setTimeout(() => console.log('timer ran'), 1); +process.on('exit', () => console.log('exit handler')); +for (const value of [1, 2, 3]) { + if (value === 2) return 'ignored value'; + console.log('value', value); +} +console.log('not reached'); +function hoisted() { + return 'hoisted'; +} diff --git a/tests/corpus/8065-cjs-top-level-return/package.json b/tests/corpus/8065-cjs-top-level-return/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8065-cjs-top-level-return/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8066-object-hasown-delete.cjs b/tests/corpus/8066-object-hasown-delete.cjs new file mode 100644 index 000000000..11668b521 --- /dev/null +++ b/tests/corpus/8066-object-hasown-delete.cjs @@ -0,0 +1,23 @@ +'use strict'; +// Object.hasOwn over nullish receivers and as a value, and `delete` of +// properties JavaScript inference types as required. +const o = { a: 1, b: undefined }; +console.log(Object.hasOwn(o, 'a'), Object.hasOwn(o, 'b'), Object.hasOwn(o, 'c')); +console.log(Object.hasOwn([1, 2], 1), Object.hasOwn([1, 2], 2), Object.hasOwn(Object.create({ x: 1 }), 'x')); +for (const value of [null, undefined]) { + try { + Object.hasOwn(value, 'a'); + } catch (e) { + console.log(e.name, e.message); + } +} +const pick = Math.random() < 2 ? { k: 1 } : null; +console.log(Object.hasOwn(pick, 'k')); +const hasOwn = Object.hasOwn; +console.log(hasOwn({ k: 1 }, 'k'), hasOwn({}, 'k'), [{ a: 1 }, {}].map((x) => hasOwn(x, 'a')).join()); +const record = { a: 1, b: 2 }; +delete record.a; +console.log(Object.keys(record).join(), Object.hasOwn(record, 'a'), record.a); +const table = { one: 1, two: 2 }; +for (const key of Object.keys(table)) if (key === 'one') delete table[key]; +console.log(JSON.stringify(table)); diff --git a/tests/corpus/8067-assert-throws-validators.cjs b/tests/corpus/8067-assert-throws-validators.cjs new file mode 100644 index 000000000..189f4221a --- /dev/null +++ b/tests/corpus/8067-assert-throws-validators.cjs @@ -0,0 +1,22 @@ +'use strict'; +// assert.throws and assert.rejects with validation functions, constructor +// functions, and untyped expected values (Node's expectedException). +const assert = require('assert'); +function show(label, fn) { + try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, JSON.stringify(e.message), e.generatedMessage, e.operator, e.actual && e.actual.message); } +} +function check(err) { return err.message === 'x'; } +show('named false', () => assert.throws(() => { throw new Error('y'); }, check)); +show('named true', () => assert.throws(() => { throw new Error('x'); }, check)); +show('custom message', () => assert.throws(() => { throw new Error('y'); }, check, 'custom')); +show('returns 1', () => assert.throws(() => { throw new Error('y'); }, () => 1)); +show('non-error thrown', () => assert.throws(() => { throw 'str'; }, (v) => v === 'str')); +show('non-error fails', () => assert.throws(() => { throw 42; }, () => undefined)); +function MyErr(m) { this.message = m; } +show('constructor', () => assert.throws(() => { throw new MyErr('q'); }, MyErr)); +function untyped(expected) { assert.throws(() => { throw new Error('u'); }, expected); } +show('untyped validator', () => untyped((e) => e.message === 'u')); +show('untyped validator fails', () => untyped(function v() { return false; })); +show('untyped object', () => untyped({ message: 'u' })); +assert.rejects(Promise.reject(new Error('r')), (e) => e.message === 'r').then(() => console.log('rejects passed')); +assert.rejects(Promise.reject(new Error('r')), () => false).catch((e) => console.log('rejects', JSON.stringify(e.message), e.operator)); diff --git a/tests/corpus/8076-assert-instance-identity.cjs b/tests/corpus/8076-assert-instance-identity.cjs new file mode 100644 index 000000000..e8cedf3d6 --- /dev/null +++ b/tests/corpus/8076-assert-instance-identity.cjs @@ -0,0 +1,50 @@ +'use strict'; +// assert.strictEqual and notStrictEqual compare class instances by +// reference: two instances, an instance and the same instance read back +// through an untyped value, and an instance a method returns. Symbols +// compare by identity too. +const assert = require('assert'); +const EventEmitter = require('events'); + +class Box { + constructor(v) { + this.v = v; + } + self() { + return this; + } +} + +const b = new Box(1); +const c = new Box(1); +assert.strictEqual(b, b.self()); +assert.notStrictEqual(b, c); +const e = new EventEmitter(); +assert.strictEqual(e, e.setMaxListeners(1)); +const untyped = [e, b][0]; +assert.strictEqual(untyped, e); +assert.strictEqual(e, untyped); +assert.notStrictEqual(untyped, b); + +try { + assert.strictEqual(b, c); +} catch (error) { + console.log(error.code); + console.log(error.message); +} +try { + assert.notStrictEqual(b, b.self(), 'same box'); +} catch (error) { + console.log(error.code, error.message); +} +// A symbol read back through an untyped value is the same symbol. +const symbol = Symbol('s'); +const read = Reflect.get({ k: symbol }, 'k'); +assert.strictEqual(read, symbol); +assert.notStrictEqual(read, Symbol('s')); +try { + assert.strictEqual(read, Symbol('other')); +} catch (error) { + console.log(error.code, error.message.split('\n')[0]); +} +console.log('done'); diff --git a/tests/corpus/8078-instanceof-object-function.cjs b/tests/corpus/8078-instanceof-object-function.cjs new file mode 100644 index 000000000..22f212269 --- /dev/null +++ b/tests/corpus/8078-instanceof-object-function.cjs @@ -0,0 +1,77 @@ +'use strict'; +// `x instanceof Object` and `x instanceof Function` on untyped values follow +// Node: objects are instances of Object unless their prototype chain ends +// in null first, and functions (classes and arrows included) are instances +// of Function. +class Shape {} +class Square extends Shape {} + +const nullProto = Object.create(null); +const underNull = Object.create(nullProto); +const regrown = Object.create(null); +Object.setPrototypeOf(regrown, {}); +const orphan = function orphan() {}; +Object.setPrototypeOf(orphan, null); +function declared() {} + +const values = { + 'plain object': {}, + array: [1, 2], + 'Object.create(null)': nullProto, + 'object under a null-prototype object': underNull, + 'Object.create(null) given a prototype': regrown, + 'Object.create(plain object)': Object.create({ a: 1 }), + 'parsed JSON': JSON.parse('{"a":[1]}'), + 'function declaration': declared, + 'bound function': declared.bind(null), + 'arrow function': () => 1, + 'async function': async () => {}, + class: Square, + 'class instance': new Square(), + 'function with a null prototype': orphan, + map: new Map(), + date: new Date(0), + 'regular expression': /x/, + error: new TypeError('t'), + 'typed array': new Uint8Array(2), + promise: Promise.resolve(1), + number: 1, + string: 's', + boolean: false, + null: null, + undefined: undefined, + symbol: Symbol('s'), + bigint: 10n, +}; + +for (const [label, value] of Object.entries(values)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} + +function describe(value) { + if (!(value instanceof Object)) return 'primitive or null-prototype'; + return value instanceof Function ? 'function' : 'object'; +} +console.log([nullProto, {}, describe, 0].map(describe).join(', ')); + +// Prototype chains through functions and objects, and Object.prototype +// itself, whose own prototype is null. +const base = function base() {}; +const fromFunction = {}; +Object.setPrototypeOf(fromFunction, base); +const throughObject = {}; +Object.setPrototypeOf(throughObject, fromFunction); +const nullRooted = function nullRooted() {}; +Object.setPrototypeOf(nullRooted, null); +const reparented = function reparented() {}; +Object.setPrototypeOf(reparented, fromFunction); +const chains = { + objectPrototype: Object.getPrototypeOf({}), + fromFunction, + throughObject, + nullRooted, + reparented, +}; +for (const [label, value] of Object.entries(chains)) { + console.log(`${label}: Object ${value instanceof Object}, Function ${value instanceof Function}`); +} diff --git a/tests/corpus/8106-shared-typed-array-set.ts b/tests/corpus/8106-shared-typed-array-set.ts new file mode 100644 index 000000000..7e20aad72 --- /dev/null +++ b/tests/corpus/8106-shared-typed-array-set.ts @@ -0,0 +1,97 @@ +// TypedArray.prototype.set where the source, the target or both are views of +// a SharedArrayBuffer: same element kinds (bytes copied as is, NaN payloads +// included), different kinds (converted per element), offsets, overlapping +// ranges of one buffer, Buffer targets and out-of-bounds offsets. +function bits(label: string, buffer: ArrayBufferLike): void { + const words = new Uint32Array(buffer); + const out: string[] = []; + for (let i = 0; i < words.length; i++) out.push(words[i]!.toString(16)); + console.log(label, out.join(",")); +} + +const shared = new SharedArrayBuffer(32); +const bytes = new Uint8Array(shared); + +// Private source into a shared target, then shared into shared and shared into private. +bytes.set(new Uint8Array([1, 2, 3, 4, 5]), 2); +console.log("private into shared", bytes.subarray(0, 8).join(",")); +const other = new Uint8Array(new SharedArrayBuffer(8)); +other.set(bytes.subarray(2, 7)); +console.log("shared into shared", other.join(",")); +const local = new Uint8Array(12); +local.set(other, 3); +console.log("shared into private", local.join(",")); + +// Overlapping ranges of the same buffer, forward and backward. +const overlap = new Uint8Array(new SharedArrayBuffer(10)); +overlap.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); +overlap.set(overlap.subarray(0, 6), 3); +console.log("overlap forward", overlap.join(",")); +overlap.set(overlap.subarray(4, 10), 1); +console.log("overlap backward", overlap.join(",")); + +// Same kind, wider elements: a signaling NaN keeps its bits. +const floatBits = new Uint32Array(new SharedArrayBuffer(8)); +floatBits[0] = 0x7fa00001; +floatBits[1] = 0xffc00002; +const floatsShared = new Float32Array(floatBits.buffer); +const floatsLocal = new Float32Array(2); +floatsLocal.set(floatsShared); +bits("f32 shared into private", floatsLocal.buffer); +const floatsBack = new Float32Array(new SharedArrayBuffer(8)); +floatsBack.set(floatsLocal); +bits("f32 private into shared", floatsBack.buffer); + +const doubleBits = new Uint32Array(new SharedArrayBuffer(16)); +doubleBits[0] = 5; +doubleBits[1] = 0x7ff40000; +doubleBits[2] = 0; +doubleBits[3] = 0xc0090000; +const doublesShared = new Float64Array(doubleBits.buffer); +const doublesCopy = new Float64Array(new SharedArrayBuffer(16)); +doublesCopy.set(doublesShared); +bits("f64 shared into shared", doublesCopy.buffer); +console.log("f64 values", doublesCopy[1]); + +const signed = new Int16Array(new SharedArrayBuffer(8)); +signed.set(new Int16Array([-32768, -1, 32767]), 1); +console.log("i16 private into shared", signed.join(",")); + +// Different kinds convert each element. +const clamped = new Uint8ClampedArray(new SharedArrayBuffer(4)); +clamped.set(new Int16Array([300, -5, 128, 255])); +console.log("i16 into shared clamped", clamped.join(",")); +const wrapped = new Uint8Array(new SharedArrayBuffer(4)); +wrapped.set(new Int16Array(signed.buffer, 2, 3)); +console.log("shared i16 into shared u8", wrapped.join(",")); +const narrowed = new Int8Array(4); +narrowed.set(new Float64Array(doublesShared.buffer, 8, 1)); +narrowed.set(new Uint8Array([200, 127]), 2); +console.log("f64 and u8 into i8", narrowed.join(",")); +const fromClamped = new Uint8Array(new SharedArrayBuffer(4)); +fromClamped.set(clamped); +console.log("shared clamped into shared u8", fromClamped.join(",")); + +// Buffer views over shared memory take the same paths. +const buf = Buffer.from(shared, 16, 8); +buf.set(Buffer.from("scriptc"), 1); +console.log("buffer", buf.toString("latin1", 1, 8), bytes[17], bytes[23]); +const into = Buffer.alloc(4); +into.set(buf.subarray(1, 4)); +console.log("buffer into private", into.toString("hex")); + +// Offsets past the end throw before writing. +for (const offset of [-1, 30, 31]) { + try { + bytes.set(new Uint8Array([9, 9]), offset); + console.log("set at", offset, bytes[30], bytes[31]); + } catch (error) { + console.log("set at", offset, (error as Error).name, (error as Error).message); + } +} +try { + floatsBack.set(new Float32Array(3)); +} catch (error) { + console.log("too long", (error as Error).name, (error as Error).message); +} +console.log("unchanged", floatsBack.join(",")); diff --git a/tests/corpus/8107-shared-typed-array-set-workers.ts b/tests/corpus/8107-shared-typed-array-set-workers.ts new file mode 100644 index 000000000..6be32668b --- /dev/null +++ b/tests/corpus/8107-shared-typed-array-set-workers.ts @@ -0,0 +1,63 @@ +// TypedArray.prototype.set on SharedArrayBuffer views in a program with +// workers: the same cases on the main thread and in a worker (same element +// kinds with NaN payloads, overlapping ranges of one buffer, converted +// kinds), and a worker's set into a buffer that the main thread reads after +// a message. +import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads"; + +interface Job { + shared: SharedArrayBuffer; +} + +function hex(buffer: ArrayBufferLike): string { + const words = new Uint32Array(buffer); + const out: string[] = []; + for (let i = 0; i < words.length; i++) out.push(words[i]!.toString(16)); + return out.join(","); +} + +function cases(where: string): string[] { + const lines: string[] = []; + const bytes = new Uint8Array(new SharedArrayBuffer(12)); + bytes.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + bytes.set(bytes.subarray(0, 8), 4); + lines.push(`${where} overlap forward ${bytes.join(",")}`); + bytes.set(bytes.subarray(5, 12), 0); + lines.push(`${where} overlap backward ${bytes.join(",")}`); + + const floatBits = new Uint32Array(new SharedArrayBuffer(8)); + floatBits[0] = 0x7f800001; + floatBits[1] = 0x7fc12345; + const local = new Float32Array(2); + local.set(new Float32Array(floatBits.buffer)); + const back = new Float32Array(new SharedArrayBuffer(12)); + back.set(local, 1); + lines.push(`${where} f32 ${hex(local.buffer)} ${hex(back.buffer)}`); + + const halves = new Uint16Array(new SharedArrayBuffer(8)); + halves.set(new Uint16Array([65535, 1, 2]), 1); + const signed = new Int16Array(4); + signed.set(halves); + const clamped = new Uint8ClampedArray(new SharedArrayBuffer(4)); + clamped.set(signed); + lines.push(`${where} kinds ${halves.join(",")} ${signed.join(",")} ${clamped.join(",")}`); + return lines; +} + +if (isMainThread) { + for (const line of cases("main")) console.log(line); + const shared = new SharedArrayBuffer(16); + const job: Job = { shared }; + const worker = new Worker(new URL(import.meta.url), { workerData: job }); + worker.on("message", (message: string) => { + console.log(message); + console.log("worker wrote", new Uint8Array(shared).join(",")); + void worker.terminate(); + }); +} else { + const { shared } = workerData as Job; + const target = new Uint8Array(shared); + target.set(new Uint8Array([9, 8, 7]), 2); + target.set(target.subarray(2, 5), 10); + parentPort!.postMessage(cases("worker").join("\n")); +} diff --git a/tests/corpus/8190-assert-function-values.cjs b/tests/corpus/8190-assert-function-values.cjs new file mode 100644 index 000000000..f27bf22dc --- /dev/null +++ b/tests/corpus/8190-assert-function-values.cjs @@ -0,0 +1,45 @@ +'use strict'; +// assert methods are function values: stored, passed and called through +// aliases, they assert as the methods do, and every read is the same +// function. A constant bound to a method and called directly reports +// the call as Node quotes it. +const assert = require('assert'); +const { strictEqual, deepStrictEqual } = require('assert'); +const strict = require('assert/strict'); +const EventEmitter = require('events'); +console.log(typeof assert.fail, assert.fail === assert.fail, strict.equal === assert.strictEqual, strictEqual === assert.strictEqual); +const ok = assert.ok; +ok(1); +const tryIt = (label, fn) => { try { fn(); console.log(label, 'passed'); } catch (e) { console.log(label, e.name, e.code, JSON.stringify(e.message), e.generatedMessage); } }; +tryIt('ok', () => ok(0)); +tryIt('ok none', () => ok()); +tryIt('ok msg', () => ok(false, 'nope')); +tryIt('ok err', () => ok(false, new TypeError('own'))); +tryIt('fail', () => assert.fail.call(null)); +tryIt('fail msg', () => (0, assert.fail)('boom')); +tryIt('equal', () => assert.equal.call(null, 1, '1')); +tryIt('equal fail', () => [assert.equal][0](1, 2)); +tryIt('notEqual fail', () => [assert.notEqual][0](1, '1')); +tryIt('strict', () => strictEqual(1, 2)); +tryIt('deep', () => deepStrictEqual({ a: 1 }, { a: 1 })); +tryIt('deep fail', () => [deepStrictEqual][0]({ a: 1 }, { a: 2 }, 'differs')); +tryIt('ifError', () => [assert.ifError][0](null)); +const checks = [assert.ok, assert.fail, assert.strictEqual]; +checks.forEach((check, i) => console.log(i, typeof check, check === checks[i], check.name)); +[1, 'x', true].forEach((value) => assert.ok(value)); +console.log('done'); + +// The comparison methods need both operands, as Node's ERR_MISSING_ARGS +// TypeError says, also through function values. +const comparisons = [assert.equal, assert.notEqual, assert.strictEqual, assert.notStrictEqual, + assert.deepEqual, assert.notDeepEqual, assert.deepStrictEqual, assert.notDeepStrictEqual]; +for (const compare of comparisons) { + for (const args of [[], [1]]) { + try { + compare(...args); + console.log(compare.name, args.length, 'passed'); + } catch (e) { + console.log(compare.name, args.length, e.name, e.code, e.message); + } + } +} diff --git a/tests/corpus/8191-assert-function-values.ts b/tests/corpus/8191-assert-function-values.ts new file mode 100644 index 000000000..8ef24153b --- /dev/null +++ b/tests/corpus/8191-assert-function-values.ts @@ -0,0 +1,16 @@ +// assert methods read as values in TypeScript: a constant alias called +// directly, a stored value, and a method passed as a callback. +import assert from "node:assert"; +const eq = assert.strictEqual; +eq(1, 1); +const fail = assert.fail; +const checks: unknown[] = [assert.ok, assert.fail]; +console.log(typeof checks[0], checks[1] === fail); +try { + eq(1, 2, "one is not two"); +} catch (error) { + console.log((error as Error).message); +} +const run = (fn: (v: unknown) => void) => fn(true); +run(assert.ok); +console.log("done"); diff --git a/tests/corpus/8193-counted-loop-products.ts b/tests/corpus/8193-counted-loop-products.ts new file mode 100644 index 000000000..d347aee76 --- /dev/null +++ b/tests/corpus/8193-counted-loop-products.ts @@ -0,0 +1,59 @@ +// Nested counted loops whose bounds arrive at run time, with the counters' +// products reaching bitwise operators. Starts and limits take the integer +// copy or the double copy: negative, fractional, NaN, -0, and starts or +// limits past 2^25, past which the guarded copy no longer proves products +// exact. +function a(i: number, j: number): number { + return (((i + j) * (i + j + 1)) >>> 1) + i + 1; +} + +function rows(start: number, end: number, n: number): number[] { + const out: number[] = []; + for (let i = start; i < end; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += 1 / a(i, j); + out.push(t); + } + return out; +} + +function mixed(start: number, end: number, n: number): number { + let h = 0; + for (let i = start; i < end; i++) { + for (let j = start; j < n; j++) h = (h + ((i - j) * (j - i + 3) | 0)) | 0; + } + return h; +} + +function wide(start: number, count: number): number[] { + const out: number[] = []; + for (let i = start; i < start + count; i++) out.push(((i * (i + 1)) >>> 0) ^ (i * i) | 0); + return out; +} + +const args = process.argv.slice(2).map(Number); +const big = 2 ** 25; +for (const [start, end, n] of [ + [0, 4, 6], + [-3, 2, 5], + [1.5, 4, 3], + [-0, 2, 2], + [NaN, 2, 2], + [big - 2, big + 2, 3], + [0, 3, args.length > 0 ? big + 1 : 4], +]) { + console.log(start, end, n, rows(start!, end!, Math.min(n!, 8)).map((x) => x.toFixed(12)).join(" ")); + console.log("mixed", mixed(start!, end!, Math.min(n!, 8))); +} +for (const start of [0, -5, 2 ** 26, 2 ** 31 - 2, 2 ** 52, -(2 ** 40), 0.5]) console.log(start, wide(start, 4).join(" ")); +console.log(Object.is(((-0 * 5) >>> 0), 0), a(-1, 0), a(0, -1), a(2 ** 30, 2 ** 30)); + +// Single-expression functions inlined at calls that pass literals and +// locals, also read as values and called with other arguments. +function mix(x: number, y: number): number { + return ((x * 31 + y) ^ (y << 3)) % 1000; +} +const table = [mix, (x: number, y: number) => x - y]; +let acc = 0; +for (let k = 0; k < 5; k++) acc += mix(k, 7) + mix(3, k) + table[k % 2]!(k, 2) + mix(k * 2, args.length); +console.log(acc, mix(2 ** 40, -0), mix(NaN, 1), mix(Infinity, 2), [1, 2, 3].map((v) => mix(v, v)).join()); diff --git a/tests/corpus/8195-wrapping-products-near-2-53.ts b/tests/corpus/8195-wrapping-products-near-2-53.ts new file mode 100644 index 000000000..c42104290 --- /dev/null +++ b/tests/corpus/8195-wrapping-products-near-2-53.ts @@ -0,0 +1,25 @@ +// Products that reach bitwise operators with operands whose ranges are +// known, around 2^53: below it the double product is exact, and from it on +// the double product rounds, so the 32-bit result comes from the rounded +// value (3 * 3002399751580331 is 2^53 + 1, which rounds to 2^53). +const pairs: [number, number][] = [ + [3, 3002399751580331], + [-3, 3002399751580331], + [2 ** 26, 2 ** 27], + [94906265, 94906267], + [94906267, 94906267], + [7, 1286742750677284], + [65535, 137443147777], +]; +for (const [a, b] of pairs) console.log(a, b, (a * b) | 0, (a * b) >>> 0, (a * b) ^ 1, (a * b) << 3); + +// The same products with literal operands, which the compiler sees whole. +console.log((3 * 3002399751580331) | 0, (3 * 3002399751580331) >>> 0); +console.log((-3 * 3002399751580331) | 0, (2 ** 26 * 2 ** 27) | 0); +console.log((94906267 * 94906267) | 0, (94906265 * 94906267) >>> 0); + +// A counted loop whose counter multiplies a constant past 2^53. +const step = 1286742750677284; +let h = 0; +for (let k = 5; k < 9; k++) h = (h + ((k * step) | 0)) | 0; +console.log(h); diff --git a/tests/corpus/8232-native-streams-untyped.cjs b/tests/corpus/8232-native-streams-untyped.cjs new file mode 100644 index 000000000..fb1c1f0c1 --- /dev/null +++ b/tests/corpus/8232-native-streams-untyped.cjs @@ -0,0 +1,80 @@ +// Native streams passed to untyped helpers: push, unshift, the listener +// members, setDefaultEncoding and readableFlowing answer through the +// untyped value as on the typed one, also for a program subclass and in +// object mode. +const { Readable, Writable } = require("stream"); + +function feed(s) { + console.log("push", s.push("a"), s.push("b", "utf8"), s.unshift("z")); + console.log("counts", s.listenerCount("data"), s.eventNames().join(",")); + s.on("custom", (x, y) => { + console.log("custom", x, y); + }); + s.emit("custom", 1, 2); + s.removeAllListeners("custom"); + console.log("after removeAll", s.listenerCount("custom"), s.readableFlowing); + s.push(null); +} + +const r = new Readable({ read() {} }); +const chunks = []; +r.on("data", (c) => chunks.push(String(c))); +r.on("end", () => console.log("end", chunks.join("|"))); +feed(r); + +class Source extends Readable { + _read() {} +} +const sub = new Source(); +sub.on("data", (c) => console.log("source data", String(c))); +console.log("source push", sub instanceof Readable); +(function (s) { + console.log(s.push("from untyped"), s.readableLength); + s.push(null); +})(sub); + +const objects = new Readable({ objectMode: true, read() {} }); +console.log("flowing before", objects.readableFlowing); +objects.on("data", (c) => console.log("object data", c)); +(function (s) { + console.log(s.push({ n: 1 }), s.push(undefined), s.push(2), s.readableFlowing); + s.push(null); +})(objects); + +function drain(w) { + w.setDefaultEncoding("latin1"); + w.write("é"); + w.end(); +} +const w = new Writable({ + write(c, e, cb) { + console.log("wrote", c.length, e); + cb(); + }, +}); +w.on("finish", () => console.log("finish")); +drain(w); + +// unshift(string, encoding) puts the decoded bytes at the front; an +// unknown encoding throws. +const bytes = new Readable({ read() {} }); +const parts = []; +bytes.on("data", (c) => parts.push(c.toString("hex"))); +bytes.on("end", () => console.log("unshift bytes", parts.join("|"))); +(function (s) { + console.log(s.unshift("e9", "hex"), s.unshift("aGk=", "base64"), s.unshift("é", "latin1"), s.unshift("x", "utf8")); + try { + s.unshift("x", "bogus"); + } catch (e) { + console.log(e.code, e.message); + } + s.push(null); +})(bytes); +const text = new Readable({ read() {} }); +text.setEncoding("utf8"); +text.on("data", (c) => console.log("unshift text", JSON.stringify(c))); +(function (s) { + s.unshift("c3a9", "hex"); + s.unshift("41", "hex"); + s.push(null); +})(text); diff --git a/tests/corpus/8233-string-decoder-values.cjs b/tests/corpus/8233-string-decoder-values.cjs new file mode 100644 index 000000000..475a31d57 --- /dev/null +++ b/tests/corpus/8233-string-decoder-values.cjs @@ -0,0 +1,33 @@ +// A StringDecoder passed to untyped code keeps its identity and its +// pending bytes, and answers write, end and encoding as in Node. +const { StringDecoder } = require("string_decoder"); + +function feed(decoder, chunks) { + let out = ""; + for (const chunk of chunks) out += decoder.write(chunk); + return out + decoder.end(); +} + +const euro = Buffer.from("€ and 😀"); +const utf8 = new StringDecoder("utf-8"); +const parts = []; +for (let i = 0; i < euro.length; i++) parts.push(euro.subarray(i, i + 1)); +console.log(feed(utf8, parts), utf8.encoding); + +const hex = new StringDecoder("hex"); +console.log(feed(hex, [Buffer.from([1, 2]), Buffer.from([255])]), hex.encoding); + +const utf16 = new StringDecoder("ucs2"); +const pending = [Buffer.from([0x61]), Buffer.from([0x00, 0x62]), Buffer.from([0x00])]; +console.log(JSON.stringify(feed(utf16, pending)), utf16.encoding); + +function passThrough(d) { + return d; +} +const kept = passThrough(utf8); +console.log(kept === utf8, typeof kept, kept.write("text"), kept.end(Buffer.from([0xf0, 0x9f]))); +try { + kept.write(null); +} catch (e) { + console.log(e.code, e.message); +} diff --git a/tests/corpus/8234-handle-typeof.ts b/tests/corpus/8234-handle-typeof.ts new file mode 100644 index 000000000..f18240130 --- /dev/null +++ b/tests/corpus/8234-handle-typeof.ts @@ -0,0 +1,24 @@ +// typeof of the objects the runtime keeps as numeric handles answers +// "object", as Node's does: readline interfaces, timers, async-local +// stores, diagnostics channels and event-loop histograms. +import * as readline from "node:readline"; +import { PassThrough } from "node:stream"; +import { AsyncLocalStorage } from "node:async_hooks"; +import * as dc from "node:diagnostics_channel"; +import { monitorEventLoopDelay } from "node:perf_hooks"; + +const rl = readline.createInterface({ input: new PassThrough(), terminal: false }); +const timeout = setTimeout(() => {}, 1); +const interval = setInterval(() => {}, 1000); +const immediate = setImmediate(() => {}); +const store = new AsyncLocalStorage(); +const channel = dc.channel("handles"); +const tracing = dc.tracingChannel("handles"); +const histogram = monitorEventLoopDelay(); +const wrapped: Number = 5; +console.log(typeof rl, typeof timeout, typeof interval, typeof immediate); +console.log(typeof store, typeof channel, typeof tracing, typeof histogram, typeof wrapped); +clearTimeout(timeout); +clearInterval(interval); +clearImmediate(immediate); +rl.close(); diff --git a/tests/corpus/8260-class-prototype-views.mjs b/tests/corpus/8260-class-prototype-views.mjs new file mode 100644 index 000000000..0f7b080bf --- /dev/null +++ b/tests/corpus/8260-class-prototype-views.mjs @@ -0,0 +1,178 @@ +// A class's prototype as the prototype of other objects: ES5 constructors +// that inherit from a class through Object.create, Object.setPrototypeOf and +// util.inherits, and plain objects that adopt a class prototype. Inherited +// methods, accessors and calls between methods run with the object as the +// receiver. +import util from "node:util"; + +class Shape { + constructor(name) { + this.name = name; + } + get label() { + return `${this.name} (${this.sides()} sides)`; + } + sides() { + return 0; + } + describe() { + return `${this.label}: area ${this.area().toFixed(2)}`; + } + area() { + return 0; + } +} + +class Polygon extends Shape { + sides() { + return this.points.length; + } + perimeter() { + let total = 0; + for (let i = 0; i < this.points.length; i++) { + const [x1, y1] = this.points[i]; + const [x2, y2] = this.points[(i + 1) % this.points.length]; + total += Math.hypot(x2 - x1, y2 - y1); + } + return total; + } +} + +// Object.create(Class.prototype) for an ES5 subclass. +function Square(size) { + this.name = "square"; + this.size = size; + this.points = [ + [0, 0], + [size, 0], + [size, size], + [0, size], + ]; +} +Square.prototype = Object.create(Polygon.prototype); +Square.prototype.constructor = Square; +Square.prototype.area = function () { + return this.size * this.size; +}; + +const square = new Square(3); +console.log(square.describe()); +console.log(square.perimeter(), square.sides(), square.label); +console.log(square instanceof Square, square instanceof Polygon, square instanceof Shape); +console.log(Object.getPrototypeOf(Square.prototype) === Polygon.prototype); +console.log(Object.getPrototypeOf(Polygon.prototype) === Shape.prototype); +console.log(Object.keys(square).join(",")); +const inherited = []; +for (const key in square) inherited.push(key); +console.log(inherited.join(",")); + +// Object.setPrototypeOf on an ES5 constructor's prototype. +function Circle(radius) { + this.name = "circle"; + this.radius = radius; +} +Circle.prototype.area = function () { + return Math.PI * this.radius * this.radius; +}; +Object.setPrototypeOf(Circle.prototype, Shape.prototype); +const circle = new Circle(2); +console.log(circle.describe(), circle instanceof Shape, circle instanceof Polygon); + +// util.inherits with a class as the superCtor. +function Triangle(a, b, c) { + this.name = "triangle"; + this.points = [a, b, c]; +} +util.inherits(Triangle, Polygon); +Triangle.prototype.area = function () { + const [[x1, y1], [x2, y2], [x3, y3]] = this.points; + return Math.abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2); +}; +const triangle = new Triangle([0, 0], [4, 0], [0, 3]); +console.log(Triangle.super_ === Polygon, triangle instanceof Polygon); +console.log(triangle.describe(), triangle.perimeter()); + +// A plain object that adopts a class prototype (rehydrating parsed data). +const parsed = JSON.parse('{"name":"lot","points":[[0,0],[2,0],[2,1]]}'); +Object.setPrototypeOf(parsed, Polygon.prototype); +console.log(parsed.sides(), parsed.perimeter().toFixed(3), parsed.label, parsed instanceof Polygon); +const fresh = Object.create(Shape.prototype); +fresh.name = "blank"; +console.log(fresh.describe(), fresh instanceof Shape, Object.getPrototypeOf(fresh) === Shape.prototype); + +// Native instances keep their own methods and answers. +const native = new Polygon("native"); +native.points = [ + [0, 0], + [1, 0], + [0, 1], +]; +console.log(native.describe(), native.perimeter().toFixed(3), native instanceof Polygon); + +// util.inspect names each object by its prototype's constructor. +function Plain() { + this.v = 1; +} +Object.setPrototypeOf(Plain.prototype, Shape.prototype); +console.log(new Square(1).size, fresh, new Plain(), Object.prototype.toString.call(fresh)); +console.log(util.inspect(new Plain()), util.inspect(parsed, { depth: 0 })); + +// A subclass keeps its own constructor below a base whose prototype other +// objects inherit: `new this.constructor()` in a base method builds the +// subclass, also while the subclass's prototype holds a replaceable method. +class Disc extends Shape { + area() { + return 3; + } + rebuild() { + return new this.constructor("copy of " + this.name); + } +} +const areaValue = Shape.prototype.area; +const disc = new Disc("d"); +const rebuilt = disc.rebuild(); +console.log(disc.constructor === Disc, rebuilt instanceof Disc, rebuilt.area(), rebuilt.name, typeof areaValue); + +// Deleting a reflected prototype's constructor: instances read the +// constructor their base class's prototype holds, then Object's; a +// replacement answers again. +class Root { + hi() { + return "hi"; + } +} +class Middle extends Root {} +class Tip extends Middle {} +const members = [new Middle(), new Tip(), new Root(), {}]; +const constructorNames = () => members.map((member) => member.constructor.name).join(","); +console.log(constructorNames(), Object.getOwnPropertyNames(Object.getPrototypeOf(new Middle())).join("|")); +delete Object.getPrototypeOf(new Middle()).constructor; +console.log(constructorNames(), Object.getOwnPropertyNames(Object.getPrototypeOf(new Middle())).join("|")); +console.log(new Middle().constructor === Root, new Tip().constructor === Tip); +delete Object.getPrototypeOf(new Root()).constructor; +console.log(constructorNames(), new Middle().constructor === Object); +Middle.prototype.constructor = function Named() {}; +console.log(constructorNames()); + +// A reflected prototype whose constructor is an accessor: an instance's read runs the getter with the +// instance as `this`, also where the instance's class inherits it. +class Facet { + constructor(tag) { + this.tag = tag; + } +} +class Tile extends Facet {} +class Brick extends Tile {} +const facets = [new Tile("s"), new Brick("c"), new Facet("p")]; +const facetTags = () => + facets.map((facet) => (typeof facet.constructor === "string" ? facet.constructor : facet.constructor.name)).join(","); +Object.defineProperty(Tile.prototype, "constructor", { + get() { + return "on " + this.tag; + }, + configurable: true, +}); +delete Object.getPrototypeOf(new Brick("x")).constructor; +console.log(facetTags()); +delete Object.getPrototypeOf(new Tile("x")).constructor; +console.log(facetTags()); diff --git a/tests/corpus/8290-child-channel-members/main.ts b/tests/corpus/8290-child-channel-members/main.ts new file mode 100644 index 000000000..78ed52de1 --- /dev/null +++ b/tests/corpus/8290-child-channel-members/main.ts @@ -0,0 +1,28 @@ +import { fork, spawn } from "node:child_process"; + +// `send`, `disconnect` and `channel` exist only on a child created with an +// IPC channel, and stay after the channel closes; the other members are +// on every child. +const describe = (label: string, child: ReturnType): string => + `${label}: send ${"send" in child} disconnect ${"disconnect" in child} channel ${"channel" in child} kill ${"kill" in child} pid ${"pid" in child}`; + +const lines: string[] = []; +const plain = spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }); +lines.push(describe("spawn", plain)); +const forked = fork(new URL("./worker.ts", import.meta.url), [], { + stdio: ["ignore", "ignore", "inherit", "ipc"], +}); +lines.push(describe("fork", forked)); +forked.once("message", () => { + forked.disconnect(); + lines.push(describe("fork after disconnect", forked)); +}); +forked.once("exit", () => { + lines.push(describe("fork after exit", forked)); +}); +plain.once("exit", () => { + lines.push(describe("spawn after exit", plain)); +}); +process.once("exit", () => { + console.log(lines.sort().join("\n")); +}); diff --git a/tests/corpus/8290-child-channel-members/worker.ts b/tests/corpus/8290-child-channel-members/worker.ts new file mode 100644 index 000000000..96401ea1a --- /dev/null +++ b/tests/corpus/8290-child-channel-members/worker.ts @@ -0,0 +1 @@ +process.send?.({ value: 1 }); diff --git a/tests/corpus/class-abort-controller-fields.ts b/tests/corpus/class-abort-controller-fields.ts new file mode 100644 index 000000000..b82f11a0a --- /dev/null +++ b/tests/corpus/class-abort-controller-fields.ts @@ -0,0 +1,52 @@ +// Class fields typed AbortController and AbortSignal (plain, optional, +// `| undefined`, `| null`, in arrays) hold the live handles: aborting +// through the field is seen through every other reference, and optional +// calls on a nullable field skip an empty slot. +class Editor { + private abort: AbortController | undefined; + private signal: AbortSignal | null = null; + private readonly lifetime = new AbortController(); + history: AbortController[] = []; + last?: AbortSignal; + start(): AbortSignal { + this.abort?.abort(); + this.abort = new AbortController(); + this.history.push(this.abort); + this.signal = this.abort.signal; + this.last = this.signal; + return this.signal; + } + cancel(reason?: string): boolean { + this.abort?.abort(reason); + return this.signal?.aborted ?? false; + } + reason(): unknown { + return this.signal?.reason; + } + dispose(): boolean { + this.lifetime.abort(); + return this.lifetime.signal.aborted; + } +} + +const idle = new Editor(); +console.log(idle.cancel(), idle.reason()); + +const e = new Editor(); +const first = e.start(); +let events = 0; +first.addEventListener("abort", () => { + events++; +}); +const second = e.start(); +console.log(first.aborted, second.aborted, events, e.history.length); +console.log(e.cancel("stop"), e.reason(), second.aborted, second.reason); +console.log(e.history[0]!.signal === first, e.last === second, e.dispose()); + +// A nullable local takes the same optional call. +let maybe: AbortController | undefined = e.history.length > 5 ? new AbortController() : undefined; +maybe?.abort(); +console.log(maybe?.signal.aborted); +maybe = new AbortController(); +maybe?.abort("late"); +console.log(maybe?.signal.aborted, maybe?.signal.reason); diff --git a/tests/corpus/empty-mapped-alias-types.ts b/tests/corpus/empty-mapped-alias-types.ts new file mode 100644 index 000000000..43f3ed831 --- /dev/null +++ b/tests/corpus/empty-mapped-alias-types.ts @@ -0,0 +1,33 @@ +// Mapped types instantiated over concrete arguments whose key filter keeps +// no key (a per-kind model catalog with no models of that kind, +// `Record`, `Partial<{}>`) are the empty object type: values flow +// through them like any `{}`-typed value. +type Groups = Record>; +type Key = { [A in keyof G]: keyof G[A] }[keyof G] & string; +type KeyOf = Extract, `${T}:${string}`>; +type Id = K extends `${string}:${infer M}` ? M : never; +type Catalog = { + [K in KeyOf as Id]: { id: Id; name: string }; +}; + +const data = { api: { "chat:a": { id: "a", name: "A" }, "chat:b": { id: "b", name: "B" } } }; + +const chat: Catalog = { a: { id: "a", name: "A" }, b: { id: "b", name: data.api["chat:b"].name } }; +const image: Catalog = {}; +const video: Catalog = image; +console.log(chat.a.name, chat.b.id, JSON.stringify(image), video === image); + +const none: Record = {}; +const partial: Partial<{}> = { extra: 1 } as {}; +console.log(JSON.stringify(none), JSON.stringify(partial)); + +function describe(value: Catalog): string { + return JSON.stringify(value); +} +const catalogs: Catalog[] = [image, {}, none]; +console.log(describe(image), describe({}), catalogs.map(describe).join(" ")); + +// A module-level alias without type arguments whose filter keeps no key. +type Flags = { [K in "a" | "b" as K extends "c" ? K : never]: boolean }; +const flags: Flags = {}; +console.log(JSON.stringify(flags), JSON.stringify([flags, none])); diff --git a/tests/corpus/instanceof-primitive-wrappers-typed.ts b/tests/corpus/instanceof-primitive-wrappers-typed.ts new file mode 100644 index 000000000..dc065e569 --- /dev/null +++ b/tests/corpus/instanceof-primitive-wrappers-typed.ts @@ -0,0 +1,14 @@ +// Typed operands tested against the primitive wrapper constructors: always +// false, with the operand's side effects kept. +let reads = 0; +function pick(n: number): unknown { + reads++; + return n === 0 ? "text" : n === 1 ? 7 : n === 2 ? { ok: true } : [n]; +} +const seen: string[] = []; +for (let i = 0; i < 4; i++) { + const value = pick(i); + seen.push(`${value instanceof String}/${value instanceof Number}/${value instanceof Boolean}`); +} +const record: object = { a: 1 }; +console.log(seen.join(" "), record instanceof Object, record instanceof String, reads); diff --git a/tests/corpus/instanceof-primitive-wrappers.mjs b/tests/corpus/instanceof-primitive-wrappers.mjs new file mode 100644 index 000000000..11bcaba07 --- /dev/null +++ b/tests/corpus/instanceof-primitive-wrappers.mjs @@ -0,0 +1,24 @@ +// `instanceof` against the primitive wrapper constructors (Boolean, Number, +// String, BigInt, Symbol), as typebox's value guards test: false for +// primitives and for every object, and the operand still evaluates. +function IsBoolean(value) { + return value instanceof Boolean; +} +function IsNumber(value) { + return value instanceof Number; +} +function IsString(value) { + return value instanceof String; +} +function IsUint8Array(value) { + return value instanceof globalThis.Uint8Array; +} +const values = [true, 1, "s", 2n, Symbol("k"), null, undefined, new Uint8Array(2), {}, [], () => 1, new Date(0)]; +for (const v of values) + console.log(typeof v, IsBoolean(v), IsNumber(v), IsString(v), IsUint8Array(v), v instanceof BigInt, v instanceof Symbol); +let calls = 0; +const next = () => { + calls++; + return { n: calls }; +}; +console.log(next() instanceof Number, next() instanceof globalThis.String, calls); diff --git a/tests/coverage-fixtures/startup-fences.js b/tests/coverage-fixtures/startup-fences.js index 22aad4e0a..5e1d00b62 100644 --- a/tests/coverage-fixtures/startup-fences.js +++ b/tests/coverage-fixtures/startup-fences.js @@ -2,8 +2,8 @@ // a fence inside a function runs when the function is called. "use strict"; var value = JSON.parse("{}"); -console.log(value instanceof Object); +console.log(value instanceof WeakRef); function check(other) { - return other instanceof Object; + return other instanceof WeakRef; } console.log(check([])); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index 823c64c40..7cf96bc5b 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -5,8 +5,8 @@ // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error // surface, empty shapes, non-literal objects, doesNotReject shapes — -// Node's own TypeError), ifError over uncomparable types, and assert -// functions as values. Every reached site terminates in a named +// Node's own TypeError), ifError over uncomparable types, and assert.throws +// as a value. Every reached site terminates in a named // diagnostic — never a miscompare, never silence. import assert from "node:assert"; import * as assertNs from "node:assert"; @@ -47,8 +47,8 @@ assert.throws(() => { throw new Error("x"); }, {}); // Node rejects the empty sh void assert.doesNotReject(async () => {}, { name: "TypeError" }); // Node's own TypeError (function or RegExp only) assert.ifError(new Map()); // no honest ifError rendering for this type -const eq = assert.strictEqual; // assert functions as values -eq(1, 1); +const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run +throwsFn(() => {}); interface Tree { label: string; kids: Tree[] } const tree: Tree = { label: "t", kids: [] }; diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index d28333178..0952436e1 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -21,8 +21,6 @@ const fmt = new Intl.NumberFormat(); // Constructors. const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced -const mutableDate = new Date(0); -mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice const wr = new WeakRef({}); const px = new Proxy({ a: 1 }, {}); const buf = new ArrayBuffer(8); @@ -73,14 +71,6 @@ function pick(a: string | number, b: number | boolean): boolean { } const picked = pick(1, 2); -function throwDate(): void { - throw new Date(0); // the exception cell cannot preserve Date's object kind -} -throwDate(); -function* dateGenerator(): Generator { - yield 1; -} -dateGenerator().throw(new Date(0)); // Related classes may store one object under different union tags. A // structural arm beside them leaves no identity-preserving comparison. class FenceBase { diff --git a/tests/fixtures/server/cases/http-in-members/driver.mjs b/tests/fixtures/server/cases/http-in-members/driver.mjs new file mode 100644 index 000000000..9c989bb70 --- /dev/null +++ b/tests/fixtures/server/cases/http-in-members/driver.mjs @@ -0,0 +1,7 @@ +import http from "node:http"; +const port = Number(process.argv[2]); +http.get({ host: "127.0.0.1", port, path: "/" }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => console.log(res.statusCode, body)); +}); diff --git a/tests/fixtures/server/cases/http-in-members/main.ts b/tests/fixtures/server/cases/http-in-members/main.ts new file mode 100644 index 000000000..80babfc13 --- /dev/null +++ b/tests/fixtures/server/cases/http-in-members/main.ts @@ -0,0 +1,18 @@ +// `in` tests on native Node handles answer from Node's declared members. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +const server = createServer((req, res) => { + console.log("request", "method" in req, "setHeader" in res, "writeHead" in res); + res.end("ok"); +}); +console.log("closeAllConnections" in server, "closeIdleConnections" in server, "listen" in server); +server.listen(0, "127.0.0.1", () => { + process.stderr.write(`PORT ${(server.address() as AddressInfo).port}\n`); +}); +server.on("request", () => { + setTimeout(() => { + if ("closeAllConnections" in server) server.closeAllConnections(); + server.close(() => console.log("closed")); + }, 10); +}); diff --git a/tests/harness/__snapshots__/assert-class-fences.ts.txt b/tests/harness/__snapshots__/assert-class-fences.ts.txt index 29d2e4668..f70683e22 100644 --- a/tests/harness/__snapshots__/assert-class-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-class-fences.ts.txt @@ -16,7 +16,7 @@ assert-class-fences.ts:12:16 - error SC2020: 'assert.Assert instances used as va hint: bind `new Assert(...)` with literal options to a const and only call methods on it (`const a = new Assert({ diff: 'full' }); a.strictEqual(x, y)`) -assert-class-fences.ts:13:22 - error SC2001: values of type 'object | Assert' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +assert-class-fences.ts:13:22 - error SC2001: values of type 'object | Assert' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 12 | const passed = new Assert(); 13 | function useInstance(a: Assert | object): void { diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index 2ab78f117..041c20e9b 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -104,12 +104,12 @@ assert-fences.ts:48:16 - error SC2020: 'assert.ifError over 'Map hint: numbers, strings, booleans, Errors, null/undefined, and unions of those are the lowered forms -assert-fences.ts:50:12 - error SC2020: 'assert.strictEqual' is part of the standard library types but has no scriptc lowering yet +assert-fences.ts:50:18 - error SC2020: 'assert.throws' is part of the standard library types but has no scriptc lowering yet 49 | - 50 | const eq = assert.strictEqual; // assert functions as values - | ^~~~~~~~~~~~~~~~~~ - 51 | eq(1, 1); + 50 | const throwsFn = assert.throws; // assert.throws as a value: it takes the function to run + | ^~~~~~~~~~~~~ + 51 | throwsFn(() => {}); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) diff --git a/tests/harness/__snapshots__/class-interface-refinements.ts.txt b/tests/harness/__snapshots__/class-interface-refinements.ts.txt index 9d7d7b87f..89f3ead6d 100644 --- a/tests/harness/__snapshots__/class-interface-refinements.ts.txt +++ b/tests/harness/__snapshots__/class-interface-refinements.ts.txt @@ -7,7 +7,7 @@ class-interface-refinements.ts:8:26 - error SC2002: record shapes must match exa hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead -class-interface-refinements.ts:12:16 - error SC2001: values of type 'AddedStorage' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +class-interface-refinements.ts:12:16 - error SC2001: values of type 'AddedStorage' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 11 | interface AddedStorage extends Box { extra: string; } 12 | function added(view: AddedStorage): string { return view.extra; } @@ -23,7 +23,7 @@ class-interface-refinements.ts:13:13 - error SC2004: uses of 'added' inherit the hint: the declaration of 'added' did not compile — fix the diagnostic reported there and these sites clear with it -class-interface-refinements.ts:16:18 - error SC2001: values of type 'ChangedMethod' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +class-interface-refinements.ts:16:18 - error SC2001: values of type 'ChangedMethod' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 15 | interface ChangedMethod extends Box { read(): number; } 16 | function changed(view: ChangedMethod): number { return view.read(); } diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index 75e15760b..d6dd27c5a 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -5,7 +5,7 @@ date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toLocaleTimeString' is pa | ^~~~~~~~~~~~~~~~~~~~~~~~~ 5 | date.toLocaleString(); - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet @@ -14,4 +14,4 @@ date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part o | ^~~~~~~~~~~~~~~~~~~~~ 6 | - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/dynamic-representable-types.ts.txt b/tests/harness/__snapshots__/dynamic-representable-types.ts.txt index 21402b597..6241894f1 100644 --- a/tests/harness/__snapshots__/dynamic-representable-types.ts.txt +++ b/tests/harness/__snapshots__/dynamic-representable-types.ts.txt @@ -1,4 +1,4 @@ -dynamic-representable-types.ts:7:7 - error SC2001: values of type '[number, (string | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +dynamic-representable-types.ts:7:7 - error SC2001: values of type '[number, (string | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 6 | const bare: any = 41; 7 | const optTuple: [number, string?] = [1]; diff --git a/tests/harness/__snapshots__/generics.ts.txt b/tests/harness/__snapshots__/generics.ts.txt index 4dfc8019f..86dd94bb9 100644 --- a/tests/harness/__snapshots__/generics.ts.txt +++ b/tests/harness/__snapshots__/generics.ts.txt @@ -12,7 +12,7 @@ generics.ts:30:10 - error SC1090: '.join()' on arrays of this element type (numb | ^~~~~~~~~~~ 31 | } -generics.ts:44:7 - error SC2001: values of type 'typeof Box' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +generics.ts:44:7 - error SC2001: values of type 'typeof Box' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 43 | // The uninstantiated family as a value: no thunk, no single ctor ABI. 44 | const BoxAlias = Box; diff --git a/tests/harness/__snapshots__/maps-surface.ts.txt b/tests/harness/__snapshots__/maps-surface.ts.txt index c1db0576d..ed4ebb79f 100644 --- a/tests/harness/__snapshots__/maps-surface.ts.txt +++ b/tests/harness/__snapshots__/maps-surface.ts.txt @@ -1,4 +1,4 @@ -maps-surface.ts:21:11 - error SC2001: values of type '(v: number, k: string, theMap: Map) => void' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +maps-surface.ts:21:11 - error SC2001: values of type '(v: number, k: string, theMap: Map) => void' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 20 | // The forEach callback receives (value, key) — no third map parameter. 21 | m.forEach((v, k, theMap) => console.log(k, v)); diff --git a/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt b/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt index d4eb59168..43d4eff77 100644 --- a/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/regex-match-destructuring-fences.ts.txt @@ -5,7 +5,7 @@ regex-match-destructuring-fences.ts:6:16 - error SC1031: rest bindings over rege | ^~~~~~~ 7 | const { input = "none" } = m; -regex-match-destructuring-fences.ts:6:19 - error SC2001: values of type '{ [n: number]: string; input: string; 0: string; length: number; toString(): string; toLocaleString(): string; toLocaleString(locales: string | string[], options?: (NumberFormatOptions & DateTimeFormatOptions) | undefined): string; ... 39 more ...; with(index: number, value: string): string[]; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +regex-match-destructuring-fences.ts:6:19 - error SC2001: values of type '{ [n: number]: string; input: string; 0: string; length: number; toString(): string; toLocaleString(): string; toLocaleString(locales: string | string[], options?: (NumberFormatOptions & DateTimeFormatOptions) | undefined): string; ... 39 more ...; with(index: number, value: string): string[]; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 5 | const m = /(a)(b)?/.exec("ab")!; 6 | const { index, ...rest } = m; diff --git a/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt b/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt index 4d3dd52c3..dfdb00720 100644 --- a/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt +++ b/tests/harness/__snapshots__/retired-engine-2103-optional-tuple-elements.ts.txt @@ -43,7 +43,7 @@ retired-engine-2103-optional-tuple-elements.ts:19:1 - error SC2004: uses of 'f2' hint: the declaration of 'f2' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:20:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:20:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 19 | f2([]); 20 | const t: [string?, number?] = ["x"]; @@ -59,7 +59,7 @@ retired-engine-2103-optional-tuple-elements.ts:21:13 - error SC2004: uses of 't' hint: the declaration of 't' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:22:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:22:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 21 | console.log(t.length, `${t[0]} ${t[1]}`); 22 | const full: [string?, number?] = ["y", 7]; @@ -75,14 +75,14 @@ retired-engine-2103-optional-tuple-elements.ts:23:13 - error SC2004: uses of 'fu hint: the declaration of 'full' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:24:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:24:7 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 23 | console.log(full.length, `${full[0]} ${full[1]}`); 24 | const none: [string?, number?] = []; | ^~~~ 25 | console.log(none.length); -retired-engine-2103-optional-tuple-elements.ts:24:34 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:24:34 - error SC2001: values of type '[(string | undefined)?, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 23 | console.log(full.length, `${full[0]} ${full[1]}`); 24 | const none: [string?, number?] = []; @@ -98,7 +98,7 @@ retired-engine-2103-optional-tuple-elements.ts:25:13 - error SC2004: uses of 'no hint: the declaration of 'none' did not compile — fix the diagnostic reported there and these sites clear with it -retired-engine-2103-optional-tuple-elements.ts:27:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +retired-engine-2103-optional-tuple-elements.ts:27:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 26 | // A rest-element tuple rides the same island representation. 27 | const r: [string, ...number[]] = ["head", 1, 2, 3]; diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index c183f7cdc..313a9391b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -55,88 +55,65 @@ stdlib-fence.ts:23:11 - error SC2020: 'new Date with spread arguments' is part o 22 | // Constructors. 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | const mutableDate = new Date(0); + 24 | const wr = new WeakRef({}); hint: new Date(), new Date(milliseconds), new Date(dateString), and new Date(year, monthIndex, ...) with listed arguments are supported -stdlib-fence.ts:25:1 - error SC2020: 'Date.prototype.setUTCFullYear with arguments' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:24:7 - error SC2020: 'WeakRef<{}>' is part of the standard library types but has no scriptc lowering yet - 24 | const mutableDate = new Date(0); - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 26 | const wr = new WeakRef({}); - - hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering - -stdlib-fence.ts:26:7 - error SC2020: 'WeakRef<{}>' is part of the standard library types but has no scriptc lowering yet - - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - 26 | const wr = new WeakRef({}); + 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced + 24 | const wr = new WeakRef({}); | ^~ - 27 | const px = new Proxy({ a: 1 }, {}); + 25 | const px = new Proxy({ a: 1 }, {}); hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -stdlib-fence.ts:26:12 - error SC2020: 'new WeakRef' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:24:12 - error SC2020: 'new WeakRef' is part of the standard library types but has no scriptc lowering yet - 25 | mutableDate.setUTCFullYear(2024); // mutation is outside the read-only Date slice - 26 | const wr = new WeakRef({}); + 23 | const d = new Date(...([2024, 0] as const)); // spread field arguments stay fenced + 24 | const wr = new WeakRef({}); | ^~~~~~~~~~~~~~~ - 27 | const px = new Proxy({ a: 1 }, {}); + 25 | const px = new Proxy({ a: 1 }, {}); hint: deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead -stdlib-fence.ts:41:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet +stdlib-fence.ts:39:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet - 40 | const norm = "abc".normalize(); - 41 | const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower + 38 | const norm = "abc".normalize(); + 39 | const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 42 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) + 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) -stdlib-fence.ts:43:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:41:19 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - 42 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) - 43 | const localized = (1234.5).toLocaleString(); + 40 | const fixed = (1.5).toFixed(); // digit-free toFixed lowers now (the static ties-up integer) + 41 | const localized = (1234.5).toLocaleString(); | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 44 | // Unlowered call FORMS of lowered members. (push is fully variadic now — + 42 | // Unlowered call FORMS of lowered members. (push is fully variadic now — hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") -stdlib-fence.ts:49:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:47:1 - error SC2020: '.forEach with 2 arguments' is part of the standard library types but has no scriptc lowering yet - 48 | const joined = nums.join(); // the omitted separator lowers - 49 | nums.forEach((x) => console.log(x), { unused: true }); // thisArg stays fenced + 46 | const joined = nums.join(); // the omitted separator lowers + 47 | nums.forEach((x) => console.log(x), { unused: true }); // thisArg stays fenced | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 50 | const pos = "abc".includes("b", 1); // the position form lowers now (indexOf's clamp) + 48 | const pos = "abc".includes("b", 1); // the position form lowers now (indexOf's clamp) hint: the thisArg parameter has no lowering — use an arrow function -stdlib-fence.ts:77:9 - error SC1090: throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead) are not supported yet +stdlib-fence.ts:84:10 - error SC1090: comparisons of related class values beside structural union members (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet - 76 | function throwDate(): void { - 77 | throw new Date(0); // the exception cell cannot preserve Date's object kind - | ^~~~~~~~~~~ - 78 | } - -stdlib-fence.ts:83:23 - error SC1090: .throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead) is not supported yet - - 82 | } - 83 | dateGenerator().throw(new Date(0)); - | ^~~~~~~~~~~ - 84 | // Related classes may store one object under different union tags. A - -stdlib-fence.ts:94:10 - error SC1090: comparisons of related class values beside structural union members (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) are not supported yet - - 93 | function relatedStrict(a: FenceValue, b: FenceValue): boolean { - 94 | return a === b; + 83 | function relatedStrict(a: FenceValue, b: FenceValue): boolean { + 84 | return a === b; | ^~~~~~~ - 95 | } + 85 | } -stdlib-fence.ts:97:10 - error SC2020: 'Object.is over related class values beside structural union members' is part of the standard library types but has no scriptc lowering yet +stdlib-fence.ts:87:10 - error SC2020: 'Object.is over related class values beside structural union members' is part of the standard library types but has no scriptc lowering yet - 96 | function relatedSameValue(a: FenceValue, b: FenceSub): boolean { - 97 | return Object.is(a, b); + 86 | function relatedSameValue(a: FenceValue, b: FenceSub): boolean { + 87 | return Object.is(a, b); | ^~~~~~~~~~~~~~~ - 98 | } + 88 | } hint: narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms \ No newline at end of file diff --git a/tests/harness/__snapshots__/tuples.ts.txt b/tests/harness/__snapshots__/tuples.ts.txt index a6f58710e..440904095 100644 --- a/tests/harness/__snapshots__/tuples.ts.txt +++ b/tests/harness/__snapshots__/tuples.ts.txt @@ -12,14 +12,14 @@ tuples.ts:12:6 - error SC1090: tuple indexing with a non-literal index (tuples a | ^ 13 | -tuples.ts:15:7 - error SC2001: values of type '[string, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +tuples.ts:15:7 - error SC2001: values of type '[string, (number | undefined)?]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 14 | // Optional and rest elements have no fixed arity/shape. 15 | const opt: [string, number?] = ["x"]; | ^~~ 16 | const rest: [string, ...number[]] = ["y", 1, 2]; -tuples.ts:16:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +tuples.ts:16:7 - error SC2001: values of type '[string, ...number[]]' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 15 | const opt: [string, number?] = ["x"]; 16 | const rest: [string, ...number[]] = ["y", 1, 2]; diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt index 6b5cc3566..8915968ef 100644 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ b/tests/harness/__snapshots__/union-clone-override-width.ts.txt @@ -1,4 +1,4 @@ -union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') 7 | // copy its record. The inferred spread result keeps the wider payload. 8 | const result = { ...source, payload }; diff --git a/tests/harness/corpus-costs/6307-number-map-get-set-fusion.ts.json b/tests/harness/corpus-costs/6307-number-map-get-set-fusion.ts.json new file mode 100644 index 000000000..c4ebfea74 --- /dev/null +++ b/tests/harness/corpus-costs/6307-number-map-get-set-fusion.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 4200 } diff --git a/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json b/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json new file mode 100644 index 000000000..f40375eee --- /dev/null +++ b/tests/harness/corpus-costs/7305-string-unit-loop-versions.ts.json @@ -0,0 +1 @@ +{ "plain": 10500, "san": 14500 } diff --git a/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json b/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/7307-string-unit-loop-evictions.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..97e5fc450 --- /dev/null +++ b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 6000 } diff --git a/tests/harness/corpus-costs/7983-integer-compares.ts.json b/tests/harness/corpus-costs/7983-integer-compares.ts.json new file mode 100644 index 000000000..8bfb69ce2 --- /dev/null +++ b/tests/harness/corpus-costs/7983-integer-compares.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2100 } diff --git a/tests/harness/corpus-costs/7984-untyped-array-methods.mjs.json b/tests/harness/corpus-costs/7984-untyped-array-methods.mjs.json new file mode 100644 index 000000000..5ae927c90 --- /dev/null +++ b/tests/harness/corpus-costs/7984-untyped-array-methods.mjs.json @@ -0,0 +1 @@ +{ "plain": 4100, "san": 4500 } diff --git a/tests/harness/corpus-costs/7993-json-parse-error-messages.ts.json b/tests/harness/corpus-costs/7993-json-parse-error-messages.ts.json new file mode 100644 index 000000000..85179832c --- /dev/null +++ b/tests/harness/corpus-costs/7993-json-parse-error-messages.ts.json @@ -0,0 +1 @@ +{ "plain": 3600 } diff --git a/tests/harness/corpus-costs/8002-while-byte-length-companions.ts.json b/tests/harness/corpus-costs/8002-while-byte-length-companions.ts.json new file mode 100644 index 000000000..d22ff8534 --- /dev/null +++ b/tests/harness/corpus-costs/8002-while-byte-length-companions.ts.json @@ -0,0 +1 @@ +{ "plain": 3948, "san": 3569 } diff --git a/tests/harness/corpus-costs/8020-date-setters.ts.json b/tests/harness/corpus-costs/8020-date-setters.ts.json new file mode 100644 index 000000000..ad5d9a546 --- /dev/null +++ b/tests/harness/corpus-costs/8020-date-setters.ts.json @@ -0,0 +1 @@ +{ "plain": 2891, "san": 4454 } diff --git a/tests/harness/corpus-costs/8021-date-coercions.ts.json b/tests/harness/corpus-costs/8021-date-coercions.ts.json new file mode 100644 index 000000000..cd0090be6 --- /dev/null +++ b/tests/harness/corpus-costs/8021-date-coercions.ts.json @@ -0,0 +1 @@ +{ "plain": 2589, "san": 3385 } diff --git a/tests/harness/corpus-costs/8022-date-unions.ts.json b/tests/harness/corpus-costs/8022-date-unions.ts.json new file mode 100644 index 000000000..ce89111f9 --- /dev/null +++ b/tests/harness/corpus-costs/8022-date-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 2786, "san": 3724 } diff --git a/tests/harness/corpus-costs/8023-date-clone-throw.ts.json b/tests/harness/corpus-costs/8023-date-clone-throw.ts.json new file mode 100644 index 000000000..50a23d33a --- /dev/null +++ b/tests/harness/corpus-costs/8023-date-clone-throw.ts.json @@ -0,0 +1 @@ +{ "plain": 2847, "san": 33492 } diff --git a/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json b/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json new file mode 100644 index 000000000..0007f1971 --- /dev/null +++ b/tests/harness/corpus-costs/8024-date-setters-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2645, "san": 3529 } diff --git a/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json b/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json new file mode 100644 index 000000000..cb128790d --- /dev/null +++ b/tests/harness/corpus-costs/8025-date-subclass-setters.ts.json @@ -0,0 +1 @@ +{ "plain": 2383, "san": 3249 } diff --git a/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json b/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json new file mode 100644 index 000000000..0615e7224 --- /dev/null +++ b/tests/harness/corpus-costs/8026-date-subclass-setters-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2483, "san": 3294 } diff --git a/tests/harness/corpus-costs/8027-date-setter-order.ts.json b/tests/harness/corpus-costs/8027-date-setter-order.ts.json new file mode 100644 index 000000000..aff4bec01 --- /dev/null +++ b/tests/harness/corpus-costs/8027-date-setter-order.ts.json @@ -0,0 +1 @@ +{ "plain": 2489, "san": 3288 } diff --git a/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json b/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json new file mode 100644 index 000000000..a7aeb13a5 --- /dev/null +++ b/tests/harness/corpus-costs/8028-date-setter-order-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 2430, "san": 2975 } diff --git a/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json b/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json new file mode 100644 index 000000000..bec4e42a8 --- /dev/null +++ b/tests/harness/corpus-costs/8232-native-streams-untyped.cjs.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 5000 } diff --git a/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json b/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/8233-string-decoder-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/8234-handle-typeof.ts.json b/tests/harness/corpus-costs/8234-handle-typeof.ts.json new file mode 100644 index 000000000..4c7ad07f1 --- /dev/null +++ b/tests/harness/corpus-costs/8234-handle-typeof.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 5500 } diff --git a/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json b/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json new file mode 100644 index 000000000..f06add31f --- /dev/null +++ b/tests/harness/corpus-costs/8260-class-prototype-views.mjs.json @@ -0,0 +1 @@ +{ "plain": 8037, "san": 6172 } diff --git a/tests/harness/corpus-costs/class-abort-controller-fields.ts.json b/tests/harness/corpus-costs/class-abort-controller-fields.ts.json new file mode 100644 index 000000000..28efce0e8 --- /dev/null +++ b/tests/harness/corpus-costs/class-abort-controller-fields.ts.json @@ -0,0 +1 @@ +{ "plain": 4302 } diff --git a/tests/harness/corpus-costs/empty-mapped-alias-types.ts.json b/tests/harness/corpus-costs/empty-mapped-alias-types.ts.json new file mode 100644 index 000000000..855a18b97 --- /dev/null +++ b/tests/harness/corpus-costs/empty-mapped-alias-types.ts.json @@ -0,0 +1 @@ +{ "plain": 2395 } diff --git a/tests/harness/corpus-costs/instanceof-primitive-wrappers-typed.ts.json b/tests/harness/corpus-costs/instanceof-primitive-wrappers-typed.ts.json new file mode 100644 index 000000000..d06ba3ac5 --- /dev/null +++ b/tests/harness/corpus-costs/instanceof-primitive-wrappers-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 2217 } diff --git a/tests/harness/corpus-costs/instanceof-primitive-wrappers.mjs.json b/tests/harness/corpus-costs/instanceof-primitive-wrappers.mjs.json new file mode 100644 index 000000000..68ee374b9 --- /dev/null +++ b/tests/harness/corpus-costs/instanceof-primitive-wrappers.mjs.json @@ -0,0 +1 @@ +{ "plain": 2265 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index e1831c609..1046ca6af 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -221,10 +221,10 @@ test("deferred fences in a module's top-level code report as running at startup" ]); const out = report(fixture("startup-fences.js")); expect(out).toMatch( - /runs at startup {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:13/, + /runs at startup {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:5:30/, ); expect(out).toMatch( - /deferred to runtime {3}1 site .*\n.*instanceof against this native constructor\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:10/, + /deferred to runtime {3}1 site .*\n.*'instanceof' right-hand sides other than classes declared in the program\n\s+at tests\/coverage-fixtures\/startup-fences\.js:7:27/, ); const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); expect(envelope.diagnostics.map((d) => [d.scope, d.line, d.startup])).toEqual([ diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 69e97dc6d..a0c43cd0c 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -191,11 +191,11 @@ for (const text of inputs) { expect(r.stderr).toBe(""); expect(r.stdout.split("\n")).toEqual([ "SyntaxError: Unexpected end of JSON input (same)", - "SyntaxError: Expected ',' or '}' after property value in JSON at position 19 (same)", - "SyntaxError: Bad escaped character in JSON at position 11 (same)", - "SyntaxError: Unexpected non-whitespace character after JSON at position 22 (same)", - 'SyntaxError: Unexpected token \']\', ...","x":[1,],"qty":"... is not valid JSON (same)', - "SyntaxError: Bad Unicode escape in JSON at position 21 (same)", + "SyntaxError: Unexpected number in JSON at position 19 (line 1 column 20) (same)", + "SyntaxError: Bad escaped character in JSON at position 11 (line 1 column 12) (same)", + "SyntaxError: Unexpected non-whitespace character after JSON at position 22 (line 1 column 23) (same)", + 'SyntaxError: Unexpected token \']\', ..."a","x":[1,],"qty":1}"... is not valid JSON (same)', + "SyntaxError: Bad Unicode escape in JSON at position 21 (line 1 column 22) (same)", "RangeError: Maximum call stack size exceeded (same)", "TypeError: expected number at $[0].qty, got undefined (plain ok)", "TypeError: expected number at $[1].qty, got boolean (plain ok)", @@ -268,7 +268,7 @@ try { "err TypeError: expected number at $.id, got undefined", "err TypeError: expected number at $.id, got null", "err TypeError: expected object at $, got array", - "err SyntaxError: Unexpected end of JSON input", + "err SyntaxError: Expected ',' or '}' after property value in JSON at position 39 (line 1 column 40)", "ok Infinity \uFFFD 0 ", "ok 7 reordered 0 ", "err expected number at $[1].qty, got string", diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index c8849a10f..add31681d 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -2190,3 +2190,33 @@ for (const run of runs) { ); }); }); + +describe("class prototype relinks (JS lane)", () => { + test("relinking a class prototype reached through an instance throws", async () => { + // Compiled code binds a class's inherited methods, so its prototype's + // [[Prototype]] cannot change at run time. Node would print "other"; + // the native program refuses instead of answering A's method. + const r = await compileAndRun( + "class-prototype-relink", + `'use strict'; +class A { + a() { + return 'A.a'; + } +} +class B extends A {} +const prototype = Object.getPrototypeOf(new B()); +Object.setPrototypeOf(prototype, Object.getPrototypeOf(prototype)); +console.log('same prototype', new B().a()); +Object.setPrototypeOf(prototype, { a: () => 'other' }); +console.log(new B().a()); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("same prototype A.a\n"); + expect(r.stderr).toBe( + "Uncaught Error: Object.setPrototypeOf with this value is not supported yet\n", + ); + }); +}); diff --git a/tests/harness/event-loop-cases.ts b/tests/harness/event-loop-cases.ts index 50a7d2b85..e0ecfc899 100644 --- a/tests/harness/event-loop-cases.ts +++ b/tests/harness/event-loop-cases.ts @@ -37,9 +37,11 @@ export const eventLoopCases: EventLoopCase[] = [ { title: "stdio values: 'readable' drains with read(), then fires once more at EOF", fixture: "stdio-readable.mjs", + // The first chunk waits until both readers run: a reader that starts + // after the second chunk arrives reads the two in one 'readable'. script: { writes: [ - { delayMs: 0, data: "alpha " }, + { delayMs: 500, data: "alpha " }, { delayMs: 60, data: "beta\n" }, ], end: true, diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 7eecd3c93..784dc747b 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -363,6 +363,17 @@ globals: undefined fenced expect(run.stdout).toBe(SESSION + SESSION + SESSION); }); + test("K4: untyped property caches start empty in every session", async () => { + // The shapes a session's cache entries name are freed when it ends; + // the next session's sites must not find those entries. + const { archive, outDir } = await buildLibrary("js-ic-sessions", emission); + const probe = buildProbe("js-ic-sessions", archive, outDir); + const run = runProbe(probe); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe("run: 20000\n".repeat(3)); + }); + /* ── K5/K6/K7: the trap channel ──────────────────────────────────────── */ test("K5: a trap delivers to the sink exactly once, host survives", async () => { @@ -582,6 +593,15 @@ survived, sink_calls=1 expect(run.stdout).toBe(SESSION + SESSION + SESSION); }); + platformTest("K10: property caches across sessions under ASan", async () => { + const { archive, outDir } = await buildLibrary("js-ic-sessions", emission, { sanitize: true }); + const probe = buildProbe("js-ic-sessions", archive, outDir, { sanitize: true }); + const run = runProbe(probe); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe("run: 20000\n".repeat(3)); + }); + platformTest("K10: K5/K7 under ASan", async () => { const { archive, outDir } = await buildLibrary("traps", emission, { sanitize: true }); const probe = buildProbe("traps", archive, outDir, { sanitize: true }); diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index b39daefa1..9ced69b5c 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -563,6 +563,7 @@ describe("library profile fences", () => { "stdlib.date.getUTCSeconds", "stdlib.date.now", "stdlib.date.parse", + "stdlib.date.setters", "stdlib.date.toDateString", "stdlib.date.toISOString", "stdlib.date.toString", diff --git a/tests/harness/node-parallel-runner.test.ts b/tests/harness/node-parallel-runner.test.ts new file mode 100644 index 000000000..a829c4d78 --- /dev/null +++ b/tests/harness/node-parallel-runner.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { afterAll, expect, test } from "vitest"; +// @ts-expect-error -- a plain ESM helper of the runner script +import { sameTermination, termination } from "../node-parallel/verdict.mjs"; + +const repo = resolve(import.meta.dirname, "../.."); +const scratch = mkdtempSync(join(tmpdir(), "scriptc-node-parallel-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +test("runs that end by different signals, or by a signal and an exit, differ", () => { + expect(sameTermination({ code: 0, signal: null }, { code: 0, signal: null })).toBe(true); + expect( + sameTermination({ code: null, signal: "SIGABRT" }, { code: null, signal: "SIGABRT" }), + ).toBe(true); + expect( + sameTermination({ code: null, signal: "SIGABRT" }, { code: null, signal: "SIGSEGV" }), + ).toBe(false); + expect(sameTermination({ code: 1, signal: null }, { code: null, signal: "SIGKILL" })).toBe(false); + expect(sameTermination({ code: 0, signal: null }, { code: 1, signal: null })).toBe(false); + expect(termination({ code: null, signal: "SIGSEGV" })).toBe("SIGSEGV"); + expect(termination({ code: 0, signal: null })).toBe(0); +}); + +test("a relative --node path links the checkout's fixtures into the minimal tree", () => { + const checkout = join(scratch, "node"); + mkdirSync(join(checkout, "test", "parallel"), { recursive: true }); + mkdirSync(join(checkout, "test", "fixtures"), { recursive: true }); + mkdirSync(join(checkout, "test", "common"), { recursive: true }); + writeFileSync(join(checkout, "test", "fixtures", "marker.txt"), "fixture\n"); + writeFileSync(join(checkout, "test", "common", "fixtures.js"), "module.exports = {};\n"); + const out = join(scratch, "out"); + const result = spawnSync( + process.execPath, + [ + join(repo, "tests/node-parallel/run.mjs"), + "--node", + relative(scratch, checkout), + "--pattern", + "^no-such-test$", + "--out", + out, + ], + { cwd: scratch, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + const linked = join(out, "tree", "test", "fixtures", "marker.txt"); + expect(existsSync(linked)).toBe(true); + expect(readFileSync(linked, "utf8")).toBe("fixture\n"); +}); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index f1b616578..678685103 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -237,6 +237,63 @@ assert.notSameValue(1, "1"); status: "fail", source: "assert.throws(TypeError, () => { throw 1; });", }, + { + name: "identical references", + status: "pass", + source: ` +const object = {}; +const list = [1]; +const fn = function () {}; +assert.sameValue(object, object); +assert.sameValue(list, list); +assert.sameValue(fn, fn); +assert.compareArray([object, fn], [object, fn]); +`, + }, + { + name: "a reference is not a primitive", + status: "pass", + source: + "assert.notSameValue({}, 1); assert.notSameValue([], undefined); assert.notSameValue(null, {});", + }, + { name: "reference and primitive mismatch", status: "fail", source: "assert.sameValue([], 0);" }, + { + name: "identical references are not different", + status: "fail", + source: "const object = {}; assert.notSameValue(object, object);", + }, + { + name: "array element reference and primitive mismatch", + status: "fail", + source: "assert.compareArray([{}], [1]);", + }, + { + name: "non-string messages", + status: "pass", + source: + "assert.sameValue(1, 1, false); assert.notSameValue(1, 2, 0); assert(true, null); assert.compareArray([], [], 1);", + }, + { + name: "failure with a non-string message", + status: "fail", + source: "assert.sameValue(1, 2, false);", + }, + { + name: "passing assertions do not convert their message", + status: "pass", + source: ` +const message = { toString() { throw new Error("converted"); } }; +assert(true, message); +assert.sameValue(1, 1, message); +assert.notSameValue(1, 2, message); +assert.compareArray([1, 2], [1, 2], message); +`, + }, + { + name: "array mismatch with an object message", + status: "fail", + source: 'assert.compareArray([1], [2], { toString() { return "differs"; } });', + }, ]; describe(`Test262 host assertion contract${shardSuffix()}`, () => { @@ -259,8 +316,8 @@ describe(`Test262 host assertion contract${shardSuffix()}`, () => { ["{}", "[1]", "function () {}"], (value) => `reference:${value}`, )) { - test(`reference assertions are explicitly refused: ${value}`, async () => { - const source = `const value = ${value}; try { assert.sameValue(value, value); } catch { }`; + test(`distinct reference assertions are explicitly refused: ${value}`, async () => { + const source = `try { assert.sameValue(${value}, ${value}); } catch { } try { assert.notSameValue(${value}, ${value}); } catch { }`; expect(() => runUpstream(source)).not.toThrow(); const result = await runSource(source, { sanitize }); expect(result, JSON.stringify(result)).toMatchObject({ @@ -270,8 +327,8 @@ describe(`Test262 host assertion contract${shardSuffix()}`, () => { }); } - test("array element identity assertions are refused", async () => { - const source = "const value = {}; try { assert.compareArray([value], [value]); } catch { }"; + test("distinct array element references are refused", async () => { + const source = "try { assert.compareArray([{}], [{}]); } catch { }"; expect(() => runUpstream(source)).not.toThrow(); const result = await runSource(source, { sanitize }); expect(result, JSON.stringify(result)).toMatchObject({ @@ -359,9 +416,15 @@ const propertyControls = shardSelect( }, { name: "reference value", - status: "harness-refusal", + status: "pass", source: "const f = function() {}; const o = { a: f }; verifyProperty(o, 'a', { value: f });", }, + { + name: "distinct reference value", + status: "harness-refusal", + source: + "const o = { a: function() {} }; try { verifyProperty(o, 'a', { value: function() {} }); } catch { }", + }, ], (item) => `property:${item.name}`, ); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 5d814b384..66c2934f2 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -313,6 +313,9 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { // ThreadSlots blocks and pages hold 32-bit pointers. "5727-thread-slots-basics.ts", "5729-thread-slots-values.ts", + // Weak-collection hashes mix 32-bit pointers in 64 bits. + "4591-weak-keys-union-records.ts", + "7742-weak-collections-many-keys.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-"); diff --git a/tests/library-mode/js-ic-sessions/lib.ts b/tests/library-mode/js-ic-sessions/lib.ts new file mode 100644 index 000000000..ebea59a54 --- /dev/null +++ b/tests/library-mode/js-ic-sessions/lib.ts @@ -0,0 +1,6 @@ +// The library's typed export over untyped code (work.mjs). +import { runAll } from "./work.mjs"; + +export function run(): number { + return runAll() as number; +} diff --git a/tests/library-mode/js-ic-sessions/probe.c b/tests/library-mode/js-ic-sessions/probe.c new file mode 100644 index 000000000..7dbcb56bf --- /dev/null +++ b/tests/library-mode/js-ic-sessions/probe.c @@ -0,0 +1,25 @@ +/* Three sessions of a library whose untyped code reads and writes through + * property caches: each prints the same result, and under the sanitize + * flavor no session reads a cache entry the previous session freed. */ +#include +#include + +extern void kj_init(void); +extern void kj_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx); +extern void kj_collect(void); +extern double kj_run(void); + +static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) { + (void)ctx; (void)addr; + printf("UNEXPECTED SINK: %.*s", (int)len, (const char *)msg); +} + +int main(void) { + kj_set_panic_sink(sink, NULL); + for (int session = 0; session < 3; session++) { + kj_init(); + printf("run: %.0f\n", kj_run()); + kj_collect(); + } + return 0; +} diff --git a/tests/library-mode/js-ic-sessions/profile.json b/tests/library-mode/js-ic-sessions/profile.json new file mode 100644 index 000000000..fd802b42c --- /dev/null +++ b/tests/library-mode/js-ic-sessions/profile.json @@ -0,0 +1,14 @@ +{ + "profile_format": 1, + "name": "conformance-js-ic-sessions", + "entry": "lib.ts", + "emission": "llvm", + "abi": { + "prefix": "kj_", + "init_symbol": "kj_init", + "sink_register_symbol": "kj_set_panic_sink", + "collect_symbol": "kj_collect", + "result_reset_symbol": null + }, + "exports": [{ "export": "run", "symbol": "kj_run", "params": [], "returns": "f64" }] +} diff --git a/tests/library-mode/js-ic-sessions/work.mjs b/tests/library-mode/js-ic-sessions/work.mjs new file mode 100644 index 000000000..5c1afcd18 --- /dev/null +++ b/tests/library-mode/js-ic-sessions/work.mjs @@ -0,0 +1,26 @@ +// Dynamic-tier property caches across library sessions: objects built empty +// and filled through untyped stores, read back through untyped loads, and a +// class instance at the same kind of sites. Each session must start with +// empty caches, since the shapes their entries named are freed when the +// previous session ends. +class Box { + constructor(v) { + this.v = v; + } +} +function fill(o, n) { + o.a = n; + o.b = n + 1; + return o; +} +function sum(o) { + return o.a + o.b; +} +function unbox(b) { + return b.v; +} +export function runAll() { + let total = 0; + for (let i = 0; i < 100; i++) total += sum(fill({}, i)); + return total + unbox(new Box(total)); +} diff --git a/tests/node-parallel/README.md b/tests/node-parallel/README.md new file mode 100644 index 000000000..c8ac12134 --- /dev/null +++ b/tests/node-parallel/README.md @@ -0,0 +1,23 @@ +# Node test/parallel files + +`run.mjs` builds files from a Node.js checkout's `test/parallel` with scriptc, runs each executable in that directory, and compares its exit status with Node's. It records a verdict per file: `pass` (the same exit code, or the same terminating signal), `fail` (a different exit code or signal), or `refused` (the build failed), with the first error line. + +```bash +git clone --depth 1 --branch v24.22.0 https://github.com/nodejs/node /path/to/node +node tests/node-parallel/run.mjs --node /path/to/node --pattern '^test-(url-|whatwg-url-|querystring)' +``` + +Options: + +- `--node`: a Node.js checkout, or its `test` directory. Use the version in `.node-version`. +- `--pattern`: a regular expression over file names in `test/parallel`. +- `--common`: `minimal` (the default) or `node`. + - `minimal` copies the selected files into `/tree/test/parallel` and uses `common/index.cjs` and `common/wpt.cjs` from this directory as `test/common/index.js` and `test/common/wpt.js`, with the checkout's `test/fixtures` and `test/common/fixtures.js`. The minimal common has the helpers these files use (`mustCall`, `mustNotCall`, `skip`, `expectWarning`, `invalidArgTypeHelper`, `spawnPromisified`, `hasIntl`, `isWindows`, ...) with Node's semantics, so a verdict measures the module under test rather than Node's full `test/common`. + - `node` builds the files in place against Node's own `test/common`. +- `--out`: where executables and results go (default `node_modules/.cache/scriptc-node-parallel`). +- `--cli`: the scriptc CLI entry (default `packages/cli/dist/main.js`). +- `--jobs`: concurrent files (default 4). + +Node runs each file with its `// Flags:` line. Results are written to `/results.json` and `/results.md`. + +Before a lane relies on the minimal common, run its files under Node in `/tree/test/parallel`: they should all pass there. diff --git a/tests/node-parallel/common/index.cjs b/tests/node-parallel/common/index.cjs new file mode 100644 index 000000000..b52bba638 --- /dev/null +++ b/tests/node-parallel/common/index.cjs @@ -0,0 +1,131 @@ +"use strict"; +// A minimal test/common for building Node's test/parallel files with scriptc: +// the helpers those files use, with the semantics of Node's +// test/common/index.js. Add a helper here, with Node's semantics, when a +// lane's files need it. +const assert = require("assert"); +const { inspect } = require("util"); + +const hasIntl = true; +const isDebug = false; +const isWindows = process.platform === "win32"; +const mustCallChecks = []; + +function runCallChecks(exitCode) { + if (exitCode !== 0) return; + const failed = mustCallChecks.filter((context) => { + if ("minimum" in context) return context.actual < context.minimum; + return context.actual !== context.exact; + }); + for (const context of failed) { + console.log( + "Mismatched %s function calls. Expected %s, actual %d.", + context.name, + String(context.exact ?? context.minimum), + context.actual, + ); + } + if (failed.length) process.exit(1); +} + +function noop() {} + +function mustCallInner(fn, criteria, field) { + if (typeof fn === "number") { + criteria = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + if (criteria === undefined) criteria = 1; + const context = { actual: 0, name: fn.name || "" }; + context[field] = criteria; + if (mustCallChecks.length === 0) process.on("exit", runCallChecks); + mustCallChecks.push(context); + return function (...args) { + context.actual++; + return fn.apply(this, args); + }; +} + +function mustCall(fn, exact) { + return mustCallInner(fn, exact, "exact"); +} + +function mustCallAtLeast(fn, minimum) { + return mustCallInner(fn, minimum, "minimum"); +} + +function mustNotCall(msg) { + return function mustNotCall() { + assert.fail(msg || "function should not have been called"); + }; +} + +function skip(msg) { + console.log(`1..0 # Skipped: ${msg}`); + process.exit(0); +} + +function expectWarning(name, expected, code) { + if (typeof expected === "string") expected = [[expected, code]]; + else if (expected.length !== 0 && !Array.isArray(expected[0])) + expected = [[expected[0], expected[1]]]; + const check = mustCall((warning) => { + const [message, warningCode] = expected.shift(); + assert.strictEqual(warning.name, name); + if (typeof message === "string") assert.strictEqual(warning.message, message); + else assert.match(warning.message, message); + assert.strictEqual(warning.code, warningCode); + }, expected.length); + process.on("warning", (warning) => { + if (warning.name !== name) + throw new TypeError(`"${warning.name}" was triggered without being expected.`); + check(warning); + }); +} + +function invalidArgTypeHelper(input) { + if (input == null) return ` Received ${input}`; + if (typeof input === "function") return ` Received function ${input.name}`; + if (typeof input === "object") { + const name = input.constructor?.name; + if (typeof name === "string" && name !== "") return ` Received an instance of ${name}`; + return ` Received ${inspect(input, { depth: -1 })}`; + } + let inspected = inspect(input, { colors: false }); + if (inspected.length > 28) inspected = `${inspected.slice(inspected, 0, 25)}...`; + return ` Received type ${typeof input} (${inspected})`; +} + +function spawnPromisified(...args) { + const { spawn } = require("child_process"); + let stderr = ""; + let stdout = ""; + const child = spawn(...args); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data) => { + stderr += data; + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (data) => { + stdout += data; + }); + return new Promise((resolve, reject) => { + child.on("close", (code, signal) => resolve({ code, signal, stderr, stdout })); + child.on("error", (code, signal) => reject({ code, signal, stderr, stdout })); + }); +} + +module.exports = { + hasIntl, + isDebug, + isWindows, + mustCall, + mustCallAtLeast, + mustNotCall, + skip, + expectWarning, + invalidArgTypeHelper, + spawnPromisified, +}; diff --git a/tests/node-parallel/common/wpt.cjs b/tests/node-parallel/common/wpt.cjs new file mode 100644 index 000000000..eeb7d9770 --- /dev/null +++ b/tests/node-parallel/common/wpt.cjs @@ -0,0 +1,32 @@ +"use strict"; +// The harness object of Node's test/common/wpt.js, without its WPT runner. +const assert = require("assert"); + +const harness = { + test: (fn, desc) => { + try { + fn(); + } catch (err) { + console.error(`In ${desc}:`); + throw err; + } + }, + assert_equals: assert.strictEqual, + assert_true: (value, message) => assert.strictEqual(value, true, message), + assert_false: (value, message) => assert.strictEqual(value, false, message), + assert_throws: (code, func, desc) => { + assert.throws( + func, + function (err) { + return typeof err === "object" && "name" in err && err.name.startsWith(code.name); + }, + desc, + ); + }, + assert_array_equals: assert.deepStrictEqual, + assert_unreached(desc) { + assert.fail(`Reached unreachable code: ${desc}`); + }, +}; + +module.exports = { harness }; diff --git a/tests/node-parallel/run.mjs b/tests/node-parallel/run.mjs new file mode 100644 index 000000000..e7a1e3cec --- /dev/null +++ b/tests/node-parallel/run.mjs @@ -0,0 +1,135 @@ +// Builds Node's test/parallel files with scriptc and runs them next to Node. +// See README.md for the options. +import { spawn } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { sameTermination, termination } from "./verdict.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); +const { values } = parseArgs({ + options: { + node: { type: "string" }, + pattern: { type: "string" }, + common: { type: "string", default: "minimal" }, + out: { type: "string", default: join(repo, "node_modules/.cache/scriptc-node-parallel") }, + cli: { type: "string", default: join(repo, "packages/cli/dist/main.js") }, + jobs: { type: "string", default: "4" }, + }, +}); +if (!values.node || !values.pattern || !["minimal", "node"].includes(values.common)) { + console.error( + "usage: node tests/node-parallel/run.mjs --node --pattern [--common minimal|node] [--out ] [--cli ] [--jobs ]", + ); + process.exit(2); +} +// Absolute, so the minimal lane's fixtures symlink resolves from the tree. +const nodeRoot = resolve(values.node); +const nodeTest = existsSync(join(nodeRoot, "test", "parallel")) ? join(nodeRoot, "test") : nodeRoot; +const pattern = new RegExp(values.pattern); +const out = resolve(values.out); +mkdirSync(out, { recursive: true }); + +// The minimal lane copies the selected files into a tree whose test/common is +// this directory's index.cjs and wpt.cjs, next to the checkout's fixtures and +// common/fixtures.js. +let testRoot = nodeTest; +const files = readdirSync(join(nodeTest, "parallel")) + .filter((name) => name.endsWith(".js") && pattern.test(name)) + .sort(); +if (values.common === "minimal") { + testRoot = join(out, "tree", "test"); + rmSync(join(out, "tree"), { recursive: true, force: true }); + mkdirSync(join(testRoot, "parallel"), { recursive: true }); + mkdirSync(join(testRoot, "common"), { recursive: true }); + for (const name of files) + cpSync(join(nodeTest, "parallel", name), join(testRoot, "parallel", name)); + symlinkSync(join(nodeTest, "fixtures"), join(testRoot, "fixtures")); + cpSync(join(nodeTest, "common", "fixtures.js"), join(testRoot, "common", "fixtures.js")); + cpSync(join(here, "common", "index.cjs"), join(testRoot, "common", "index.js")); + cpSync(join(here, "common", "wpt.cjs"), join(testRoot, "common", "wpt.js")); +} + +function run(cmd, args, opts) { + return new Promise((done) => { + const child = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeout); + child.on("close", (code, signal) => { + clearTimeout(timer); + done({ code, signal, stdout, stderr }); + }); + }); +} + +const firstCause = (text) => + ( + text.split("\n").find((line) => /error|Error|assert|SC\d{4}/.test(line)) ?? + text.split("\n")[0] ?? + "" + ) + .replaceAll(testRoot, "test") + .trim() + .slice(0, 300); + +async function one(file) { + const dir = join(testRoot, "parallel"); + const source = readFileSync(join(dir, file), "utf8"); + const flags = (/^\/\/ Flags: (.*)$/m.exec(source)?.[1] ?? "").split(/\s+/).filter(Boolean); + const node = await run(process.execPath, [...flags, file], { cwd: dir, timeout: 120_000 }); + const exe = join(out, "bin", `${file}.exe`); + mkdirSync(join(out, "bin"), { recursive: true }); + const build = await run(process.execPath, [values.cli, "build", file, "-o", exe], { + cwd: dir, + timeout: 900_000, + }); + const result = { file, flags: flags.join(" "), nodeExit: termination(node) }; + if (build.code !== 0) { + result.verdict = "refused"; + result.cause = firstCause(build.stdout + build.stderr); + return result; + } + const native = await run(exe, [], { cwd: dir, timeout: 120_000 }); + result.exit = termination(native); + result.verdict = sameTermination(node, native) ? "pass" : "fail"; + if (result.verdict !== "pass") result.cause = firstCause(native.stderr + native.stdout); + return result; +} + +const results = []; +const queue = [...files]; +await Promise.all( + Array.from({ length: Number(values.jobs) }, async () => { + for (let file = queue.shift(); file !== undefined; file = queue.shift()) { + const result = await one(file); + results.push(result); + console.log(`${result.verdict.padEnd(8)} ${file}${result.cause ? ` ${result.cause}` : ""}`); + } + }), +); +results.sort((a, b) => (a.file < b.file ? -1 : 1)); +const count = (verdict) => results.filter((r) => r.verdict === verdict).length; +const table = [ + "| file | verdict | first cause |", + "| --- | --- | --- |", + ...results.map((r) => `| ${r.file} | ${r.verdict} | ${(r.cause ?? "").replaceAll("|", "\\|")} |`), + "", + `pass ${count("pass")}, fail ${count("fail")}, refused ${count("refused")} of ${results.length} (test/common: ${values.common})`, +]; +writeFileSync(join(out, "results.json"), `${JSON.stringify(results, null, 2)}\n`); +writeFileSync(join(out, "results.md"), `${table.join("\n")}\n`); +console.log(table.at(-1)); diff --git a/tests/node-parallel/verdict.mjs b/tests/node-parallel/verdict.mjs new file mode 100644 index 000000000..392238f44 --- /dev/null +++ b/tests/node-parallel/verdict.mjs @@ -0,0 +1,13 @@ +// How two runs of one test file compare (run.mjs). + +/** Whether Node and the executable ended the same way: the same exit code, + * or the same terminating signal (an exit code is null when a signal ended + * the process). */ +export function sameTermination(node, native) { + return node.code === native.code && node.signal === native.signal; +} + +/** An exit code, or the signal that ended the process. */ +export function termination(result) { + return result.code ?? result.signal; +} diff --git a/tests/test262/README.md b/tests/test262/README.md index 2f8a0ed3e..231e074e6 100644 --- a/tests/test262/README.md +++ b/tests/test262/README.md @@ -32,7 +32,7 @@ The current profile is `static-adapted-v6`. It adapts strict scripts to standalo The unmodified upstream assertion helper currently reaches scriptc's JavaScript function-expando refusal. Test262 explicitly permits host implementations of harness functions in its [interpretation rules](https://github.com/tc39/test262/blob/7ab7fafa0003f73fc85c1b95d88094d33f7eb8bd/INTERPRETING.md#host-defined-functions). `harness.ts` supplies `assert`, `assert.sameValue`, `assert.notSameValue`, `assert.compareArray`, `assert.throws` for selected Error constructors, `$DONE`, and `Test262Error`. Scalar SameValue and scalar array-element comparisons use the statically compiled `node:assert/strict` implementation. Contract tests compare successful and failing assertions with the original Test262 helpers under Node, including NaN, signed zero, null/undefined, differing primitive types, and premature termination. -Reference-equality assertions terminate with a distinct harness-refusal result: conversion at a native function boundary can copy references, so this adapter cannot faithfully compare every object, array, or function identity. Tests cannot catch this refusal and accidentally pass. Sparse array comparisons are not yet handled faithfully across that boundary. The adapted `propertyHelper.js` admits direct `verifyProperty` calls with an inline descriptor literal or `undefined`; reference-valued comparisons still refuse, while other property helpers and descriptor expressions remain excluded. The adapted `testTypedArray.js` keeps the upstream constructor lists, argument factories and `testWith*` helpers. It omits the `TypedArray` (%TypedArray%) binding, so tests that name it are excluded. Its resizable and immutable buffer factories run only where `ArrayBuffer.prototype` provides `resize` and `transferToImmutable`, as the upstream feature checks intend. Contract tests compare its successful and failing uses with the upstream helper under Node. Assertion aliases, mutation, reflection, additional assertion methods, and other `includes` helpers remain excluded. +Conversion at a native function boundary can copy references, so one object, array, or function may reach the adapter as two distinct references. A copy cannot merge two references into one. `assert.sameValue`, `assert.notSameValue`, and `assert.compareArray` elements therefore treat identical references as the same value and a reference as different from every primitive. Two distinct references terminate with a distinct harness-refusal result, which tests cannot catch and turn into a pass. Assertion messages may be any value; the adapter converts them with `String` when an assertion fails, as the upstream `assert`, `assert.sameValue`, `assert.notSameValue` and `assert.compareArray` do. Sparse array comparisons are not yet handled faithfully across that boundary. The adapted `propertyHelper.js` admits direct `verifyProperty` calls with an inline descriptor literal or `undefined`; reference-valued comparisons follow the same identity rule, while other property helpers and descriptor expressions remain excluded. The adapted `testTypedArray.js` keeps the upstream constructor lists, argument factories and `testWith*` helpers. It omits the `TypedArray` (%TypedArray%) binding, so tests that name it are excluded. Its resizable and immutable buffer factories run only where `ArrayBuffer.prototype` provides `resize` and `transferToImmutable`, as the upstream feature checks intend. Contract tests compare its successful and failing uses with the upstream helper under Node. Assertion aliases, mutation, reflection, additional assertion methods, and other `includes` helpers remain excluded. Raw tests, module variants, agents, resolution negatives, and runtime negatives remain excluded. Parse negatives pass only when a source-matched syntax or vetted JavaScript early-error diagnostic appears; TypeScript type errors and harness-only failures never satisfy a negative expectation. `$262`, dynamic evaluation, observable script-global state, and other unsupported host requirements are excluded as well. The runtime and compiler continue to enforce their existing refusal boundaries for admitted source. diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index c44d32869..dce1be99c 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -17,68 +17,81 @@ export function $DONE(error?: unknown): void { console.log("__scriptc_test262_complete__"); } -function scalar(value: unknown): void { - // Native-to-unknown conversion can copy references (notably arrays). - // Refuse these assertions before comparing altered identities. Terminate +function reference(value: unknown): boolean { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +function refuse(): never { + // Native-to-unknown conversion can copy references (notably arrays), so + // two distinct references may be one value in the program. Terminate // instead of throwing so a test's catch block cannot turn this into a pass. - if ((typeof value === "object" && value !== null) || typeof value === "function") { - console.error("SCRIPTC_TEST262_HARNESS: reference equality requires an identity-preserving adapter"); - process.exit(86); + console.error("SCRIPTC_TEST262_HARNESS: reference equality requires an identity-preserving adapter"); + process.exit(86); +} + +function scalar(value: unknown): void { + if (reference(value)) refuse(); +} + +/** The upstream helpers accept any message value and convert it to a string. */ +function text(message: unknown, fallback: string): string { + return message === undefined ? fallback : String(message); +} + +/** SameValue of two operands. A copy made at the conversion boundary can only + * separate one reference into two, so identical references are the same value + * and a reference is never the same value as a primitive. Two distinct + * references refuse. */ +function same(actual: unknown, expected: unknown): boolean { + if (reference(actual) || reference(expected)) { + if (actual === expected) return true; + if (reference(actual) && reference(expected)) refuse(); + return false; + } + try { + nodeAssert.strictEqual(actual, expected); + return true; + } catch { + return false; } } -export function assert(value: unknown, message = "Expected true"): void { - if (value !== true) throw new Test262Error(message); +export function assert(value: unknown, message?: unknown): void { + if (value !== true) throw new Test262Error(text(message, "Expected true")); } export namespace assert { - export function sameValue(actual: unknown, expected: unknown, message = "Expected SameValue"): void { - scalar(actual); - scalar(expected); - try { - nodeAssert.strictEqual(actual, expected); - } catch { - throw new Test262Error(message); - } + export function sameValue(actual: unknown, expected: unknown, message?: unknown): void { + if (!same(actual, expected)) throw new Test262Error(text(message, "Expected SameValue")); } - export function notSameValue(actual: unknown, expected: unknown, message = "Expected different values"): void { - scalar(actual); - scalar(expected); - try { - nodeAssert.notStrictEqual(actual, expected); - } catch { - throw new Test262Error(message); - } + export function notSameValue(actual: unknown, expected: unknown, message?: unknown): void { + if (same(actual, expected)) throw new Test262Error(text(message, "Expected different values")); } - export function throws(expectedErrorConstructor: unknown, func: () => unknown, message = "Expected exception"): void { + export function throws(expectedErrorConstructor: unknown, func: () => unknown, message?: unknown): void { const outcome = throwsOutcome(expectedErrorConstructor, func); if (outcome === "invalid") throw new Test262Error("assert.throws requires a function to run"); if (outcome === "unsupported") { console.error("SCRIPTC_TEST262_HARNESS: assert.throws needs an Error instance"); process.exit(86); } - if (outcome !== "pass") throw new Test262Error(message); + if (outcome !== "pass") throw new Test262Error(text(message, "Expected exception")); } - export function compareArray(actual: unknown, expected: unknown, message = "Expected matching array contents"): void { + /** Converts `message` only when the contents differ, as the upstream helper does. */ + export function compareArray(actual: unknown, expected: unknown, message?: unknown): void { if (!Array.isArray(actual) || !Array.isArray(expected)) { scalar(actual); scalar(expected); - throw new Test262Error(message); + throw new Test262Error(text(message, "Expected matching array contents")); } - if (actual.length !== expected.length) throw new Test262Error(message); - for (let i = 0; i < actual.length; i++) { + let matches = actual.length === expected.length; + for (let i = 0; matches && i < actual.length; i++) { const left: unknown = i in actual ? actual[i] : undefined; const right: unknown = i in expected ? expected[i] : undefined; - scalar(left); - scalar(right); - try { - nodeAssert.strictEqual(left, right); - } catch { - throw new Test262Error(message); - } + matches = same(left, right); } + if (!matches) throw new Test262Error(text(message, "Expected matching array contents")); } }