Merge main

This commit is contained in:
Chris Tate
2026-10-21 00:08:02 -05:00
221 changed files with 11227 additions and 90439 deletions
+6 -5
View File
@@ -4,7 +4,7 @@ This is the only agent guide for the repository. Its rules apply everywhere, inc
## Markdown style
Write prose paragraphs and list items as single source lines and let editors and renderers wrap them naturally. Do not hard-wrap Markdown at a fixed column; preserve separate lines only where Markdown syntax or intentional formatting requires them, such as headings, lists, tables, blockquotes, and code blocks.
Write prose paragraphs and list items as single source lines and let editors and renderers wrap them naturally. Do not hard-wrap Markdown at a fixed column; preserve separate lines only where Markdown syntax or intentional formatting requires them, such as headings, lists, tables, blockquotes, and code blocks. The Limitations page partials under `docs/content/limitations/` are the exception: they keep one sentence per line (see Limitations page partials).
## Build and test
@@ -140,10 +140,11 @@ Never hand-edit these files:
- `packages/compiler/surface-manifest.json`
- `internal/compatibility/generated/node-v24-internal.json`
- `internal/compatibility/generated/node-v24-backlog.json`
- `docs/src/generated/node-v24-compatibility.json`
Change compiler decision sources or the implementation-owned compatibility manifests, then regenerate. Use `pnpm manifest` when compiler decision tables change and `pnpm node-compat` whenever the Node pin, compatibility manifests, compiler surface manifest, changes.
Change compiler decision sources or the implementation-owned compatibility manifests, then regenerate. Use `pnpm manifest` when compiler decision tables change and `pnpm node-compat` whenever the Node pin, compatibility manifests, compiler surface manifest, changes. `pnpm node-compat --local` reclassifies the committed ledger without fetching Node's docs; it writes the same files as `pnpm node-compat` while the Node pin is unchanged.
Never resolve a merge conflict in these files by hand. Resolve the source conflicts, then run `node scripts/regen-generated.mjs`: it takes the merged-in branch's version of each conflicted generated file, regenerates all of them from the merged sources, and stages the ones that conflicted.
The public docs artifact must not expose repository test paths, internal source identifiers, engineering evidence bookkeeping, documentation/metadata headings, command-line or configuration-only entries, or rows that are N/A in the native tier. Those remain in the complete internal census under `internal/compatibility/generated/`.
@@ -158,7 +159,7 @@ For each API family:
5. Add differential evidence that executes under Node and as a compiled binary. Cover stdout, stderr, exit status, success behavior, and important error shapes. A load-only export check does not prove behavior.
6. Update the implementation-owned source or manifest. Positive `supported` and `partial` mappings must name existing test evidence.
7. Run `pnpm manifest` if compiler surface tables changed, then run `pnpm node-compat`. Never patch generated JSON or headers.
8. Inspect the internal ledger, public artifact, and backlog diff. Confirm the intended API family changed and unrelated rows did not.
8. Inspect the internal ledger and public artifact diff, and compare `pnpm node-compat:backlog` output before and after. Confirm the intended API family changed and unrelated rows did not.
9. Run focused tests, `pnpm node-compat:check`, and the full validation gate required for the change. If docs output changed, also run the docs gate.
Compatibility commands:
@@ -196,7 +197,7 @@ The docs site is a standalone pnpm workspace under `docs/`. It uses Next.js App
- A labeled bullet (`- **Crypto:** ...`) is `docs/content/limitations/<section>/<label>.mdx`, for example `builtin-apis/crypto.mdx`. Edit that file to change the bullet.
- The other text of a section (paragraphs, code blocks, unlabeled lists) is `docs/content/limitations/<section>.mdx`; text before the first heading is `introduction.mdx`.
- `<section>` and `<label>` are the heading and label text in lowercase with each run of other characters replaced by `-`.
- Write one sentence per line; a bullet's continuation lines are indented two spaces. Markdown joins the lines, and edits to different sentences don't conflict.
- Write one sentence per line; a bullet's continuation lines are indented two spaces. Markdown joins the lines, and edits to different sentences don't conflict. `pnpm lint` fails on a line that holds two sentences, and `node docs/scripts/limitations-partials.mjs fix` splits such lines.
- To add a bullet, add its file and an include line at its position in `limitations.mdx`, with a blank line on each side. Consecutive bullet includes render as one list.
- To add a section, add the heading to `limitations.mdx` and include the section's partial below it.
@@ -13,6 +13,7 @@
After `update()` fails, for example past the AES-GCM limit of 2^36 - 32 bytes per message, every later call on that cipher throws; Node still lets `final()` run.
An output encoding that is a function or an instance of a class defining `toString` throws a coded SC2020 error.
`createECDH` and `ECDH.convertKey` support prime192v1, secp224r1, prime256v1, secp384r1, secp521r1, secp192k1, secp224k1, secp256k1, brainpoolP256r1, brainpoolP384r1, and brainpoolP512r1, on native targets, through `generateKeys()`, `computeSecret()`, `getPublicKey()`, `getPrivateKey()`, `setPrivateKey()`, and `setPublicKey()`, in compressed, uncompressed, and hybrid forms; the other curves `crypto.getCurves()` lists and wasm32-wasi builds are refused.
A call on an ECDH value whose arguments no @types/node overload accepts (`getPublicKey("HEX")`) is refused at compile time, as is `computeSecret(view, encoding)`: Node reads that encoding as the input encoding and answers a Buffer where @types/node declares a string. ECDH property reads and writes throw a coded SC2020 error.
A call on an ECDH value whose arguments no @types/node overload accepts (`getPublicKey("HEX")`) is refused at compile time, as is `computeSecret(view, encoding)`: Node reads that encoding as the input encoding and answers a Buffer where @types/node declares a string.
ECDH property reads and writes throw a coded SC2020 error.
Other public-key operations remain unsupported.
Native derivations finish before their deferred callbacks, so computation blocks the caller.
@@ -8,10 +8,15 @@ Some builtins can be stored and passed as functions:
In JavaScript, `setTimeout`, `setInterval`, `setImmediate`, `clearTimeout`, `clearInterval`, `clearImmediate`, `queueMicrotask`, and `process.nextTick` are function values wherever they are stored, including conditional and logical expressions and assignments: `typeof` answers `"function"`, identity holds across spellings, and calls schedule as the direct calls do, returning the same numeric timer handles.
Calls through these values bypass `node:test` mock timers.
Other builtin globals stored in JavaScript remain opaque identity values.
In JavaScript, `require("events")` and the `EventEmitter` class assigned to an untyped binding or stored in an object (`ReleaseEmitter = EventEmitter`) are the untyped `EventEmitter` constructor that constructor functions inherit from (see below): `new` through it builds such an emitter object, and identity and `instanceof` agree with the class.
A class can extend `require("events")` directly.
Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere.
`util.promisify` supports compile-time conversions of `child_process.execFile`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), `stream.finished` and `stream.pipeline` (to the `stream/promises` functions, as in Node), the `zlib` callback one-shots `deflate`, `inflate`, `deflateRaw`, `inflateRaw`, `gzip`, `gunzip` and `unzip` (with or without options), the `crypto` callback functions `randomBytes`, `pbkdf2`, `scrypt`, `hkdf`, `randomFill`, `randomInt` and `generateKey`, `dns.lookup` (settling with `{ address, family }`, or the address array with `all: true`) and `dns.lookupService` (settling with `{ hostname, service }`), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call.
`util.promisify` over an untyped (`any`) function returns a native wrapper that follows Node's callback rules and carries the original's `name` and `length`.
Such a function with other own properties, `util.promisify.custom` among them, throws `SC2020` where `util.promisify` runs, and the wrapper does not report Node's DEP0174 warning when the original returns a promise.
`util.styleText` supports standard formats and aliases, `#RGB` and `#RRGGBB` hex colors, arrays of formats, nested ANSI resets, and explicit color enabling with `{ validateStream: false }`.
Native calls accept standard process streams; custom native streams and mutations to `util.inspect.colors` remain unsupported.
@@ -2,3 +2,6 @@
A function stored in a slot with a different signature remains the same function for equality, array searches, `length`, and own properties.
Typed rest-parameter functions can be stored and called indirectly, including through a slot with fixed parameters, where the slot's arguments arrive in the rest array.
Builtin function values have API-specific restrictions; see [Builtin function values](#builtin-function-values).
- **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them.
A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element.
Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction.
+6 -1
View File
@@ -1 +1,6 @@
`readable-stream` 2.x, 3.x and 4.x compile as Node.js's built-in `stream` module. For 2.x and 4.x, that is the package's own behavior when the `READABLE_STREAM` environment variable is `disable`. Without that variable, Node.js runs the package's JavaScript copy of the stream classes, so a native build can differ from Node.js wherever a program depends on that difference: `instanceof` checks against the built-in classes, class identity, error messages, and timing details. The package's `lib/_stream_*.js` files also compile as the matching built-in classes, although Node.js loads the package's own implementations through them in every environment. `readable-stream` 3.x is a copy of Node.js 10's streams. Its own `disable` mode throws on Node.js 24, so no setting makes Node.js run it against the built-in classes; a native build maps the members its entry exports (the stream classes, `Stream`, `finished` and `pipeline`) to the built-in ones.
`readable-stream` 2.x, 3.x and 4.x compile as Node.js's built-in `stream` module.
For 2.x and 4.x, that is the package's own behavior when the `READABLE_STREAM` environment variable is `disable`.
Without that variable, Node.js runs the package's JavaScript copy of the stream classes, so a native build can differ from Node.js wherever a program depends on that difference: `instanceof` checks against the built-in classes, class identity, error messages, and timing details.
The package's `lib/_stream_*.js` files also compile as the matching built-in classes, although Node.js loads the package's own implementations through them in every environment.
`readable-stream` 3.x is a copy of Node.js 10's streams.
Its own `disable` mode throws on Node.js 24, so no setting makes Node.js run it against the built-in classes; a native build maps the members its entry exports (the stream classes, `Stream`, `finished` and `pipeline`) to the built-in ones.
@@ -1,2 +1,9 @@
`socket.addMembership()` and `socket.dropMembership()` on a `dgram` socket that is not bound yet throw `SC2020`; Node binds such a socket to an ephemeral port first, so bind the socket before joining a group.
The `SystemError` that the buffer size methods throw (`ERR_SOCKET_BUFFER_SIZE`, for example from `getRecvBufferSize()` before `bind()`) has Node's `message`, `code`, `errno` and `syscall`, but no `info` object.
In JavaScript, `socket.send()` also accepts lists of buffers, sends on a connected socket, and takes a completion callback, which receives `null` and the byte count, or the send's failure instead of an `'error'` event.
The callback runs after the socket's pending events, where Node runs it on the next tick.
In TypeScript, `send()` with a callback, a list, or on a connected socket fails to compile with `SC2020`.
JavaScript can read a socket's `on`, `once`, `addListener`, `removeListener`, `off`, `emit`, `send`, `close`, `bind`, `connect`, `disconnect`, `address`, `ref`, and `unref` as function values, for example `socket.emit.bind(socket)`; reading other members as values is refused with `SC1090`.
Listeners registered that way, or with untyped parameters, take only the `message`, `error`, `close`, `listening`, and `connect` events.
When a socket closes, listeners registered from TypeScript are released, so an `emit()` from JavaScript after `'close'` reaches only listeners registered from JavaScript.
@@ -1,2 +1,7 @@
`server.listen(path)` and the `socketPath` option of `http.request()` and `https.request()` use Unix domain sockets on Linux and macOS. On Windows, where Node maps these paths to named pipes, they fail with `ENOTSUP`. `server.address()` on a server listening on a Unix socket returns the socket path, as in Node, when the result is typed `string | AddressInfo | null` (the @types/node declaration). Where it is typed as the `{ address, family, port }` record alone, which cannot hold the path, the call throws an error for such a server. `listen(path, backlog, callback)` is refused at compile time; pass `listen(path, callback)`.
When the options of `http.request()` come from a variable, or from an object literal that spreads one, they are read at run time with Node's validation errors. The `auth`, `createConnection`, `family`, `hints`, `insecureHTTPParser`, `joinDuplicateHeaders`, `localAddress`, `localPort`, `lookup` and `uniqueHeaders` options then throw a runtime error when they hold a value, and so do `setHost: false` and `setDefaultHeaders: false`.
`server.listen(path)` and the `socketPath` option of `http.request()` and `https.request()` use Unix domain sockets on Linux and macOS.
On Windows, where Node maps these paths to named pipes, they fail with `ENOTSUP`.
`server.address()` on a server listening on a Unix socket returns the socket path, as in Node, when the result is typed `string | AddressInfo | null` (the @types/node declaration).
Where it is typed as the `{ address, family, port }` record alone, which cannot hold the path, the call throws an error for such a server.
`listen(path, backlog, callback)` is refused at compile time; pass `listen(path, callback)`.
When the options of `http.request()` come from a variable, or from an object literal that spreads one, they are read at run time with Node's validation errors.
The `auth`, `createConnection`, `family`, `hints`, `insecureHTTPParser`, `joinDuplicateHeaders`, `localAddress`, `localPort`, `lookup` and `uniqueHeaders` options then throw a runtime error when they hold a value, and so do `setHost: false` and `setDefaultHeaders: false`.
+54 -2
View File
@@ -21,7 +21,13 @@
* node scripts/limitations-partials.mjs split <file> [page] write the page and partials from one file
* node scripts/limitations-partials.mjs merge <base> <theirs>
* three-way merge a branch's single-file page (<theirs>, forked from
* <base>) into the current partials, then split the result */
* <base>) into the current partials, then split the result
* node scripts/limitations-partials.mjs check fail on a partial line that holds two sentences
* node scripts/limitations-partials.mjs fix put each such sentence on its own line
*
* One sentence per line keeps changes to different sentences on different
* lines, so concurrent edits merge. Markdown joins the lines with a space,
* so the rendered page is the same. */
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -139,6 +145,41 @@ function write(text, page = defaultPage) {
console.error(`${relative(docsRoot, page)}: ${partials.size} partials in ${relative(docsRoot, partialsDir)}/`);
}
// A sentence ends at `.`, `!` or `?` (after any closing `)`, `"` or `*`)
// followed by a space and a capital letter, a code span, a link or bold
// text. Abbreviations don't end one.
const sentenceEnd = /(?<!\b(?:e\.g|i\.e|etc|vs|cf))([.!?][)"*]*) (?=[A-Z`[*])/g;
/** The partial text with each sentence of a prose line on its own line;
* a bullet's sentences continue with the two-space indent. Code fences,
* headings, tables, HTML and include lines are left alone, as are code
* spans and link targets. */
export function splitSentences(text) {
let inFence = false;
const out = [];
for (const line of text.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
if (inFence || /^\s*(```|~~~|#|<|\||$)/.test(line)) {
out.push(line);
continue;
}
const indent = /^\s*(?:[-*]|\d+\.) /.test(line) ? " " : line.match(/^\s*/)[0];
// Mask code spans (all but the opening backtick, which can start a
// sentence) and link targets, so their periods never split.
const masked = line.replace(/`[^`]*`|\]\([^)]*\)/g, (m) => m[0] + "\0".repeat(m.length - 1));
const pieces = [];
let start = 0;
for (const match of masked.matchAll(sentenceEnd)) {
const end = match.index + match[1].length;
pieces.push(line.slice(start, end));
start = end + 1;
}
pieces.push(line.slice(start));
out.push(pieces[0], ...pieces.slice(1).map((piece) => indent + piece));
}
return out.join("\n");
}
function listPartials(dir = partialsDir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? listPartials(join(dir, entry.name)) : [join(dir, entry.name)],
@@ -175,9 +216,20 @@ if (command === "join") {
} finally {
rmSync(work, { recursive: true, force: true });
}
} else if (command === "check" || command === "fix") {
let unsplit = 0;
for (const file of listPartials().sort()) {
const text = readFileSync(file, "utf8");
const split = splitSentences(text);
if (split === text) continue;
unsplit++;
if (command === "fix") writeFileSync(file, split);
else console.error(`${relative(docsRoot, file)}: put each sentence on its own line (node docs/scripts/limitations-partials.mjs fix)`);
}
if (command === "check" && unsplit > 0) process.exitCode = 1;
} else if (command === "list") {
for (const file of listPartials().sort()) console.log(relative(docsRoot, file));
} else {
console.error("usage: limitations-partials.mjs join [page] | split <file> [page] | merge <base> <theirs> | list");
console.error("usage: limitations-partials.mjs join [page] | split <file> [page] | merge <base> <theirs> | check | fix | list");
process.exitCode = 2;
}
+56 -560
View File
@@ -71,841 +71,337 @@
"slug": "assert",
"title": "Assertion testing",
"entries": 29,
"apiEntries": 29,
"static": {
"supported": 0,
"partial": 23,
"refused": 0,
"not-implemented": 6,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 29
},
{
"slug": "async_context",
"title": "Asynchronous context tracking",
"entries": 20,
"apiEntries": 20,
"static": {
"supported": 0,
"partial": 7,
"refused": 0,
"not-implemented": 13,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 20
},
{
"slug": "async_hooks",
"title": "Async hooks",
"entries": 20,
"apiEntries": 20,
"static": {
"supported": 0,
"partial": 4,
"refused": 0,
"not-implemented": 16,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 20
},
{
"slug": "buffer",
"title": "Buffer",
"entries": 99,
"apiEntries": 99,
"static": {
"supported": 16,
"partial": 7,
"refused": 0,
"not-implemented": 76,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 99
},
{
"slug": "addons",
"title": "C++ addons",
"entries": 3,
"apiEntries": 3,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 0,
"by-design": 3,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 3
},
{
"slug": "n-api",
"title": "C/C++ addons with Node-API",
"entries": 192,
"apiEntries": 192,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 0,
"by-design": 192,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 192
},
{
"slug": "child_process",
"title": "Child processes",
"entries": 39,
"apiEntries": 39,
"static": {
"supported": 0,
"partial": 18,
"refused": 0,
"not-implemented": 21,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 39
},
{
"slug": "cluster",
"title": "Cluster",
"entries": 34,
"apiEntries": 34,
"static": {
"supported": 0,
"partial": 4,
"refused": 0,
"not-implemented": 30,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 34
},
{
"slug": "console",
"title": "Console",
"entries": 24,
"apiEntries": 24,
"static": {
"supported": 0,
"partial": 23,
"refused": 0,
"not-implemented": 1,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 24
},
{
"slug": "crypto",
"title": "Crypto",
"entries": 152,
"apiEntries": 152,
"static": {
"supported": 6,
"partial": 47,
"refused": 20,
"not-implemented": 79,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 152
},
{
"slug": "diagnostics_channel",
"title": "Diagnostics Channel",
"entries": 71,
"apiEntries": 71,
"static": {
"supported": 0,
"partial": 8,
"refused": 0,
"not-implemented": 63,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 71
},
{
"slug": "dns",
"title": "DNS",
"entries": 51,
"apiEntries": 51,
"static": {
"supported": 0,
"partial": 10,
"refused": 0,
"not-implemented": 41,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 51
},
{
"slug": "domain",
"title": "Domain",
"entries": 12,
"apiEntries": 12,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 12,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 12
},
{
"slug": "environment_variables",
"title": "Environment Variables",
"entries": 2,
"apiEntries": 2,
"static": {
"supported": 0,
"partial": 2,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 2
},
{
"slug": "errors",
"title": "Errors",
"entries": 478,
"apiEntries": 478,
"static": {
"supported": 0,
"partial": 10,
"refused": 0,
"not-implemented": 468,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 478
},
{
"slug": "events",
"title": "Events",
"entries": 78,
"apiEntries": 78,
"static": {
"supported": 18,
"partial": 35,
"refused": 0,
"not-implemented": 25,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 78
},
{
"slug": "fs",
"title": "File system",
"entries": 280,
"apiEntries": 280,
"static": {
"supported": 7,
"partial": 200,
"refused": 0,
"not-implemented": 73,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 280
},
{
"slug": "globals",
"title": "Globals",
"entries": 92,
"apiEntries": 92,
"static": {
"supported": 1,
"partial": 16,
"refused": 0,
"not-implemented": 75,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 92
},
{
"slug": "http",
"title": "HTTP",
"entries": 176,
"apiEntries": 176,
"static": {
"supported": 0,
"partial": 131,
"refused": 0,
"not-implemented": 45,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 176
},
{
"slug": "http2",
"title": "HTTP/2",
"entries": 168,
"apiEntries": 168,
"static": {
"supported": 0,
"partial": 14,
"refused": 2,
"not-implemented": 152,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 168
},
{
"slug": "https",
"title": "HTTPS",
"entries": 20,
"apiEntries": 20,
"static": {
"supported": 0,
"partial": 12,
"refused": 0,
"not-implemented": 8,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 20
},
{
"slug": "inspector",
"title": "Inspector",
"entries": 35,
"apiEntries": 35,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 35,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 35
},
{
"slug": "stream_iter",
"title": "Iterable Streams API",
"entries": 65,
"apiEntries": 65,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 65,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 65
},
{
"slug": "modules",
"title": "Modules: CommonJS modules",
"entries": 27,
"apiEntries": 27,
"static": {
"supported": 0,
"partial": 24,
"refused": 0,
"not-implemented": 1,
"by-design": 0,
"unreviewed": 2,
"not-applicable": 0
}
"apiEntries": 27
},
{
"slug": "esm",
"title": "Modules: ECMAScript modules",
"entries": 22,
"apiEntries": 22,
"static": {
"supported": 0,
"partial": 13,
"refused": 0,
"not-implemented": 9,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 22
},
{
"slug": "module",
"title": "Modules: node:module API",
"entries": 30,
"apiEntries": 30,
"static": {
"supported": 0,
"partial": 5,
"refused": 0,
"not-implemented": 25,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 30
},
{
"slug": "packages",
"title": "Modules: Packages",
"entries": 8,
"apiEntries": 8,
"static": {
"supported": 0,
"partial": 8,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 8
},
{
"slug": "typescript",
"title": "Modules: TypeScript",
"entries": 2,
"apiEntries": 2,
"static": {
"supported": 2,
"partial": 0,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 2
},
{
"slug": "net",
"title": "Net",
"entries": 119,
"apiEntries": 119,
"static": {
"supported": 16,
"partial": 39,
"refused": 0,
"not-implemented": 64,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 119
},
{
"slug": "os",
"title": "OS",
"entries": 24,
"apiEntries": 24,
"static": {
"supported": 0,
"partial": 24,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 24
},
{
"slug": "path",
"title": "Path",
"entries": 17,
"apiEntries": 17,
"static": {
"supported": 0,
"partial": 17,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 17
},
{
"slug": "perf_hooks",
"title": "Performance hooks",
"entries": 131,
"apiEntries": 131,
"static": {
"supported": 0,
"partial": 25,
"refused": 0,
"not-implemented": 106,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 131
},
{
"slug": "permissions",
"title": "Permissions",
"entries": 4,
"apiEntries": 4,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 4,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 4
},
{
"slug": "process",
"title": "Process",
"entries": 120,
"apiEntries": 120,
"static": {
"supported": 0,
"partial": 51,
"refused": 0,
"not-implemented": 69,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 120
},
{
"slug": "punycode",
"title": "Punycode",
"entries": 9,
"apiEntries": 9,
"static": {
"supported": 0,
"partial": 8,
"refused": 0,
"not-implemented": 1,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 9
},
{
"slug": "querystring",
"title": "Query strings",
"entries": 7,
"apiEntries": 7,
"static": {
"supported": 0,
"partial": 7,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 7
},
{
"slug": "readline",
"title": "Readline",
"entries": 43,
"apiEntries": 43,
"static": {
"supported": 0,
"partial": 42,
"refused": 0,
"not-implemented": 1,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 43
},
{
"slug": "repl",
"title": "REPL",
"entries": 14,
"apiEntries": 14,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 14,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 14
},
{
"slug": "sqlite",
"title": "SQLite",
"entries": 52,
"apiEntries": 52,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 52,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 52
},
{
"slug": "stream",
"title": "Stream",
"entries": 140,
"apiEntries": 140,
"static": {
"supported": 0,
"partial": 53,
"refused": 0,
"not-implemented": 87,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 140
},
{
"slug": "string_decoder",
"title": "String decoder",
"entries": 4,
"apiEntries": 4,
"static": {
"supported": 0,
"partial": 4,
"refused": 0,
"not-implemented": 0,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 4
},
{
"slug": "test",
"title": "Test runner",
"entries": 117,
"apiEntries": 117,
"static": {
"supported": 0,
"partial": 65,
"refused": 0,
"not-implemented": 52,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 117
},
{
"slug": "timers",
"title": "Timers",
"entries": 25,
"apiEntries": 25,
"static": {
"supported": 0,
"partial": 22,
"refused": 0,
"not-implemented": 3,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 25
},
{
"slug": "tls",
"title": "TLS/SSL",
"entries": 67,
"apiEntries": 67,
"static": {
"supported": 0,
"partial": 9,
"refused": 4,
"not-implemented": 54,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 67
},
{
"slug": "tracing",
"title": "Trace events",
"entries": 9,
"apiEntries": 9,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 9,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 9
},
{
"slug": "tty",
"title": "TTY",
"entries": 19,
"apiEntries": 19,
"static": {
"supported": 0,
"partial": 7,
"refused": 0,
"not-implemented": 12,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 19
},
{
"slug": "dgram",
"title": "UDP/datagram",
"entries": 38,
"apiEntries": 38,
"static": {
"supported": 0,
"partial": 12,
"refused": 0,
"not-implemented": 26,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 38
},
{
"slug": "url",
"title": "URL",
"entries": 62,
"apiEntries": 62,
"static": {
"supported": 0,
"partial": 42,
"refused": 0,
"not-implemented": 20,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 62
},
{
"slug": "util",
"title": "Utilities",
"entries": 109,
"apiEntries": 109,
"static": {
"supported": 0,
"partial": 72,
"refused": 0,
"not-implemented": 37,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 109
},
{
"slug": "v8",
"title": "V8",
"entries": 68,
"apiEntries": 68,
"static": {
"supported": 0,
"partial": 4,
"refused": 0,
"not-implemented": 0,
"by-design": 64,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 68
},
{
"slug": "vm",
"title": "VM",
"entries": 41,
"apiEntries": 41,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 0,
"by-design": 41,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 41
},
{
"slug": "wasi",
"title": "WASI",
"entries": 7,
"apiEntries": 7,
"static": {
"supported": 0,
"partial": 0,
"refused": 0,
"not-implemented": 0,
"by-design": 7,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 7
},
{
"slug": "webcrypto",
"title": "Web Crypto API",
"entries": 156,
"apiEntries": 156,
"static": {
"supported": 2,
"partial": 21,
"refused": 0,
"not-implemented": 133,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 156
},
{
"slug": "webstreams",
"title": "Web Streams API",
"entries": 89,
"apiEntries": 89,
"static": {
"supported": 0,
"partial": 8,
"refused": 1,
"not-implemented": 80,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 89
},
{
"slug": "worker_threads",
"title": "Worker threads",
"entries": 66,
"apiEntries": 66,
"static": {
"supported": 0,
"partial": 46,
"refused": 0,
"not-implemented": 20,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 66
},
{
"slug": "zlib",
"title": "Zlib",
"entries": 94,
"apiEntries": 94,
"static": {
"supported": 0,
"partial": 52,
"refused": 12,
"not-implemented": 30,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
}
"apiEntries": 94
}
],
"rows": [
+2 -2
View File
@@ -5,14 +5,14 @@ This workspace package owns scriptc's Node.js parity system.
- `node-v24.json` pins the Node release tag, commit, and canonical docs/source inputs.
- `static-support.json` overlays dedicated compiler paths and their internal test evidence on the compiler's generated surface manifest.
- `generated/node-v24-internal.json` is the engineering ledger, including implementation evidence and repository test paths.
- `generated/node-v24-backlog.json` is the internal tier-specific work queue. It separates verification, implementation, explicit-refusal replacement, and partial-surface audit work, and prioritizes stable Node APIs above experimental, deprecated, and legacy APIs.
- `pnpm node-compat:backlog` derives the internal tier-specific work queue from the ledger; it is not committed. It separates verification, implementation, explicit-refusal replacement, and partial-surface audit work, and prioritizes stable Node APIs above experimental, deprecated, and legacy APIs.
- `docs/src/generated/node-v24-compatibility.json` is the stripped public artifact consumed by the docs website. It contains compiler-relevant runtime APIs, statuses, verification bases, and user-facing details; documentation, metadata, command-line/configuration entries, and rows that are N/A for native execution remain internal.
Statuses are evidence-aware: `supported` and `partial` require test evidence; `refused` requires an explicit compiler diagnostic or native throwing stub; `not-implemented` is the actionable backlog for an owned API family with no implementation match; `by-design` records an architectural exclusion; `unreviewed` still needs classification; and `not-applicable` is not a runtime API for that execution tier.
Run `pnpm node-compat:backlog` from the repository root for a summary. The filters `--tier`, `--action`, `--priority`, `--chapter`, and `--status` compose; use `--format=json` or `--format=tsv` for tooling.
Run `pnpm node-compat` from the repository root after changing the Node pin or the support manifest. `pnpm node-compat:check` is the offline drift/evidence gate; `pnpm --filter @internal/compatibility check:upstream` additionally verifies the pinned upstream inputs over the network.
Run `pnpm node-compat` from the repository root after changing the Node pin or the support manifest. `pnpm node-compat --local` does the same without network access while the pin is unchanged, by reclassifying the committed ledger rows; after a merge, `node scripts/regen-generated.mjs` regenerates every generated file instead of a hand merge. `pnpm node-compat:check` is the offline drift/evidence gate; `pnpm --filter @internal/compatibility check:upstream` additionally verifies the pinned upstream inputs over the network.
## npm packages that compile as builtins
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -2,9 +2,12 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { buildBacklog } from "./generate.mjs";
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
const backlog = JSON.parse(readFileSync(`${packageRoot}generated/node-v24-backlog.json`, "utf8"));
const backlog = buildBacklog(
JSON.parse(readFileSync(`${packageRoot}generated/node-v24-internal.json`, "utf8")),
);
const args = process.argv.slice(2);
function option(name) {
+51 -23
View File
@@ -13,6 +13,8 @@
*
* pnpm node-compat fetch latest-v24.x and rewrite the snapshot
* pnpm node-compat --check fetch and fail if the snapshot would change
* pnpm node-compat --local rewrite the snapshot without fetching: reclassify
* the committed rows from the current sources
* pnpm node-compat:check offline implementation/profile drift check
*/
@@ -22,12 +24,15 @@ import { fileURLToPath } from "node:url";
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
const root = fileURLToPath(new URL("../../../", import.meta.url));
const internalOutputPath = `${packageRoot}generated/node-v24-internal.json`;
const backlogOutputPath = `${packageRoot}generated/node-v24-backlog.json`;
const publicOutputPath = `${root}docs/src/generated/node-v24-compatibility.json`;
// The docs build derives this file's values from the public artifact; a
// merge that keeps a branch's copy would bring back a file every
// compatibility change rewrites.
const retiredOutputPaths = [`${root}docs/src/generated/node-v24-compatibility-meta.json`];
// Files derived from the ledger elsewhere: the docs build computes the
// metadata from the public artifact, and `pnpm node-compat:backlog` builds
// the backlog in memory. A merge that keeps a branch's copy would bring
// back a file every compatibility change rewrites.
const retiredOutputPaths = [
`${root}docs/src/generated/node-v24-compatibility-meta.json`,
`${packageRoot}generated/node-v24-backlog.json`,
];
const compilerManifestPath = `${root}packages/compiler/surface-manifest.json`;
const compilerCompatPath = `${packageRoot}static-support.json`;
const pinPath = `${packageRoot}node-v24.json`;
@@ -447,12 +452,6 @@ function summarizeTier(rows, key, chapterPolicy) {
return tier("unreviewed", "", { source: "derived:descendants" });
}
function statusCounts(rows, key) {
const counts = Object.fromEntries(STATUSES.map((status) => [status, 0]));
for (const row of rows) if (row.scope === "api") counts[row[key].status] += 1;
return counts;
}
function verifiedAnchorIds(html) {
return new Set(
[...html.matchAll(/<a class="mark" href="#[^"]+" id="([^"]+)">#/g)].map((match) => match[1]),
@@ -524,7 +523,6 @@ function buildSnapshot({ allJson, indexMarkdown, allSha256, indexSha256 }) {
title: link.title,
entries: chapterRows.length,
apiEntries: chapterRows.filter((row) => row.scope === "api").length,
static: statusCounts(chapterRows, "static"),
});
rows.push(...chapterRows);
}
@@ -873,7 +871,7 @@ function backlogAction(tier) {
return null;
}
function buildBacklog(snapshot) {
export function buildBacklog(snapshot) {
const tasks = [];
const tierItems = { static: {} };
const priorities = { high: 0, normal: 0, low: 0 };
@@ -969,7 +967,6 @@ function projectPublicMatrix(snapshot) {
title: chapter.title,
entries: chapterRows.length,
apiEntries: chapterRows.length,
static: statusCounts(chapterRows, "static"),
});
rows.push(...chapterRows);
}
@@ -1018,6 +1015,34 @@ function publicSnapshot(snapshot) {
};
}
/** The committed ledger with every row's classification recomputed from
* the current sources. The rows themselves come from the pinned Node docs,
* so while the pin is unchanged this is what a fetching run writes. */
function reclassify(snapshot) {
if (snapshot.nodeVersion !== pin.version || snapshot.nodeCommit !== pin.commit) {
throw new Error(
`the ledger targets Node ${snapshot.nodeVersion}, the pin ${pin.version}; run 'pnpm node-compat' to fetch the new docs`,
);
}
const ctx = classificationContext();
const ancestors = [];
for (const row of snapshot.rows) {
ancestors.length = row.depth;
const parentSignature = ancestors[row.depth - 1]?.signature;
row.static = classifyStatic(row, null, ctx, parentSignature);
ancestors[row.depth] = row;
}
for (const chapter of snapshot.chapters) {
const chapterRows = snapshot.rows.filter((row) => row.chapter === chapter.slug);
chapterRows[0].static = summarizeTier(
chapterRows,
"static",
ctx.compilerCompat.chapterPolicies[chapter.slug],
);
}
return snapshot;
}
function checkLocal() {
for (const path of retiredOutputPaths) {
if (existsSync(path))
@@ -1070,9 +1095,6 @@ function checkLocal() {
throw new Error(`${row.id} exposes a row that is not applicable to native compilation`);
}
}
if (readFileSync(backlogOutputPath, "utf8") !== render(buildBacklog(snapshot))) {
throw new Error("the Node compatibility backlog is stale; run 'pnpm node-compat'");
}
if (readFileSync(publicOutputPath, "utf8") !== render(publicSnapshot(snapshot))) {
throw new Error("the docs compatibility artifact is stale; run 'pnpm node-compat'");
}
@@ -1117,6 +1139,15 @@ async function fetchText(url) {
async function main() {
const args = new Set(process.argv.slice(2));
if (args.has("--check-local")) return checkLocal();
if (args.has("--local")) {
const snapshot = reclassify(JSON.parse(readFileSync(internalOutputPath, "utf8")));
writeFileSync(internalOutputPath, render(snapshot));
writeFileSync(publicOutputPath, render(publicSnapshot(snapshot)));
console.log(
`reclassified the compatibility ledger and public artifact (${snapshot.rows.length} source rows, ${projectPublicMatrix(snapshot).rows.length} public matrix rows, ${pin.tag})`,
);
return;
}
const [allText, indexMarkdown] = await Promise.all([
fetchText(pin.allJsonUrl),
@@ -1138,14 +1169,11 @@ async function main() {
);
verifyAnchors(snapshot, htmlByChapter);
const internalRendered = render(snapshot);
const backlogRendered = render(buildBacklog(snapshot));
const publicRendered = render(publicSnapshot(snapshot));
if (args.has("--check")) {
if (readFileSync(internalOutputPath, "utf8") !== internalRendered)
throw new Error("internal Node compatibility snapshot is stale; run 'pnpm node-compat'");
if (readFileSync(backlogOutputPath, "utf8") !== backlogRendered)
throw new Error("Node compatibility backlog is stale; run 'pnpm node-compat'");
if (readFileSync(publicOutputPath, "utf8") !== publicRendered)
throw new Error("public Node compatibility snapshot is stale; run 'pnpm node-compat'");
console.log(
@@ -1154,11 +1182,11 @@ async function main() {
return;
}
writeFileSync(internalOutputPath, internalRendered);
writeFileSync(backlogOutputPath, backlogRendered);
writeFileSync(publicOutputPath, publicRendered);
console.log(
`wrote compatibility ledger, backlog, and public artifact (${snapshot.rows.length} source rows, ${projectPublicMatrix(snapshot).rows.length} public matrix rows, ${pin.tag})`,
`wrote compatibility ledger and public artifact (${snapshot.rows.length} source rows, ${projectPublicMatrix(snapshot).rows.length} public matrix rows, ${pin.tag})`,
);
}
await main();
// backlog.mjs imports buildBacklog; only running this file generates.
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+1 -2
View File
@@ -2,8 +2,7 @@
import { spawnSync } from "node:child_process";
const check = process.argv.slice(2).includes("--check");
const args = check ? ["--check"] : [];
const args = process.argv.slice(2).filter((arg) => arg === "--check" || arg === "--local");
for (const script of ["generate.mjs"]) {
const result = spawnSync(process.execPath, [`src/${script}`, ...args], { stdio: "inherit" });
+1 -1
View File
@@ -23,7 +23,7 @@
"test:sandbox": "node scripts/sandbox-test.mjs",
"test:sandbox:image": "node scripts/sandbox-image.mjs",
"test:watch": "vitest",
"lint": "oxlint --deny-warnings packages scripts internal/compatibility/src tests/harness vitest.config.ts",
"lint": "node docs/scripts/limitations-partials.mjs check && oxlint --deny-warnings packages scripts internal/compatibility/src tests/harness vitest.config.ts",
"lint:fix": "pnpm lint --fix",
"format": "oxfmt --write \"{packages,scripts,internal/compatibility/src,tests/harness}/**/*.{js,mjs,cjs,ts,mts,cts,tsx}\" vitest.config.ts .oxlintrc.jsonc .oxfmtrc.jsonc",
"format:check": "oxfmt --check \"{packages,scripts,internal/compatibility/src,tests/harness}/**/*.{js,mjs,cjs,ts,mts,cts,tsx}\" vitest.config.ts .oxlintrc.jsonc .oxfmtrc.jsonc",
+204 -69
View File
@@ -328,15 +328,18 @@ export class Client extends DispatcherBase {
}
__onTimeout(): void {
this.__timerCancel = null;
this.__timerRefresh = null;
const socket = this.__socket;
const parser = this.__parser;
// A body timeout that fires while the response is paused keeps its
// timer: resuming refreshes it, as undici's parser does.
if (this.__timerType === TIMEOUT_BODY && parser !== null && parser.paused) return;
this.__timerCancel = null;
this.__timerRefresh = null;
if (socket === null || parser === null) return;
if (this.__timerType === TIMEOUT_HEADERS) {
this.__destroySocket(socket, new HeadersTimeoutError());
} else if (this.__timerType === TIMEOUT_BODY) {
if (!parser.paused) this.__destroySocket(socket, new BodyTimeoutError());
this.__destroySocket(socket, new BodyTimeoutError());
} else if (this.__timerType === TIMEOUT_IDLE) {
this.__destroySocket(socket, new InformationalError("socket idle timeout"));
}
@@ -782,14 +785,50 @@ function errorCode(err: Error): string {
const CRLF = Buffer.from("\r\n");
/** The value of an ASCII hex digit, or -1. */
function hexValue(c: number): number {
// Chunk-size line states (llhttp's chunk_size* and chunk_extension* nodes).
const SIZE_FIRST = 0;
const SIZE_DIGITS = 1;
const SIZE_AFTER = 2;
const SIZE_EXT = 3;
const SIZE_EXT_NAME = 4;
const SIZE_EXT_VALUE = 5;
const SIZE_EXT_QUOTED = 6;
const SIZE_EXT_PAIR = 7;
const SIZE_EXT_QUOTED_DONE = 8;
const SIZE_CR = 9;
function hexDigit(c: number): number {
if (c >= 0x30 && c <= 0x39) return c - 0x30;
if (c >= 0x41 && c <= 0x46) return c - 0x37;
if (c >= 0x61 && c <= 0x66) return c - 0x57;
return -1;
}
/** tchar: ALPHA, DIGIT and !#$%&'*+-.^_`|~. */
const TOKEN_BYTES = tokenBytes();
function tokenBytes(): Uint8Array {
const table = new Uint8Array(256);
const marks = "!#$%&'*+-.^_`|~";
for (let i = 0; i < marks.length; i++) table[marks.charCodeAt(i)] = 1;
for (let c = 0x30; c <= 0x39; c++) table[c] = 1;
for (let c = 0x41; c <= 0x5a; c++) {
table[c] = 1;
table[c + 0x20] = 1;
}
return table;
}
/** qdtext: HTAB, SP and 0x21-0xff but '"' and '\\'. */
function isQuotedByte(c: number): boolean {
return c === 0x09 || c === 0x20 || (c >= 0x21 && c !== 0x22 && c !== 0x5c);
}
/** The byte after '\\' in a quoted-pair: HTAB, SP, VCHAR or obs-text. */
function isQuotedPairByte(c: number): boolean {
return c === 0x09 || c === 0x20 || (c >= 0x21 && c !== 0x7f);
}
/** An incremental HTTP/1.1 response parser (the subset of llhttp undici
* relies on): status line, raw header pairs, Content-Length, chunked and
* read-until-close bodies, trailers, and pause/resume back-pressure. */
@@ -816,14 +855,9 @@ class ResponseParser {
bytesRead: number;
trailers: Buffer[];
skipBody: boolean;
/** The chunk-size line read so far: its value, its digit count, whether
* an extension or whitespace follows the digits, and whether its CR was
* read. Extensions are skipped as they arrive, so the line keeps no
* bytes. */
sizeState: number;
sizeDigits: number;
chunkSize: number;
chunkDigits: number;
chunkExtension: boolean;
chunkCR: boolean;
constructor(client: Client, socket: Socket) {
this.client = client;
@@ -846,10 +880,9 @@ class ResponseParser {
this.bytesRead = 0;
this.trailers = [];
this.skipBody = false;
this.sizeState = SIZE_FIRST;
this.sizeDigits = 0;
this.chunkSize = 0;
this.chunkDigits = 0;
this.chunkExtension = false;
this.chunkCR = false;
}
reset(): void {
@@ -886,6 +919,7 @@ class ResponseParser {
resume(): void {
if (!this.paused) return;
if (this.client.__timerType === TIMEOUT_BODY) this.client.__refreshTimer();
this.paused = false;
this.socket.resume();
this.run();
@@ -903,18 +937,154 @@ class ResponseParser {
}
}
fail(message: string, code: string): void {
// undici 7.x wraps llhttp's reason and leaves the code unset (its
// createError indexes the error-name table with the error number);
// 5.x reports the reason and its HPE_ code.
const error =
/** A parse error with llhttp's reason: undici 7 puts it inside its
* protocol message and, as its error table maps names to numbers only,
* sets no code; undici 5 uses the reason as the message and the code. */
fail(reason: string, code: string): void {
const err =
this.client.__profile.major >= 7
? new HTTPParserError(`Response does not match the HTTP/1.1 protocol (${message})`)
: new HTTPParserError(message, code, "");
this.client.__onParseError(this.socket, error);
? new HTTPParserError(`Response does not match the HTTP/1.1 protocol (${reason})`)
: new HTTPParserError(reason, code, "");
this.client.__onParseError(this.socket, err);
this.state = 99;
}
/** Reads the chunk-size line one byte at a time as llhttp does, so a line
* that never ends holds no memory: hex digits (past 16 significant ones
* the size overflows), then extensions, checked and dropped, then CRLF.
* The 7.x profile follows llhttp 9 (undici 7), the 5.x profile llhttp 8
* in its lenient mode (undici 5). Returns true when the line is complete
* and chunkSize holds its size; false when it needs more bytes or failed. */
chunkSizeLine(): boolean {
const buf = this.buf;
if (this.sizeState === SIZE_FIRST) {
// The common line, hex digits then CRLF in this buffer, in one pass;
// any other line takes the byte loop below from its start.
let size = 0;
let digits = 0;
let j = 0;
for (; j < buf.length && j < 32; j++) {
const d = hexDigit(buf[j]!);
if (d === -1) break;
if (digits > 0 || d !== 0) {
digits++;
size = size * 16 + d;
}
}
if (j > 0 && digits <= 16 && j + 1 < buf.length && buf[j] === 0x0d && buf[j + 1] === 0x0a) {
this.chunkSize = size;
this.buf = buf.subarray(j + 2);
return true;
}
}
const v7 = this.client.__profile.major >= 7;
let i = 0;
while (i < buf.length) {
const c = buf[i]!;
const state = this.sizeState;
if (state === SIZE_FIRST || state === SIZE_DIGITS) {
const d = hexDigit(c);
if (d === -1) {
if (state === SIZE_FIRST) {
this.fail("Invalid character in chunk size", "INVALID_CHUNK_SIZE");
return false;
}
this.sizeState = SIZE_AFTER;
continue;
}
if (state === SIZE_FIRST) {
this.chunkSize = 0;
this.sizeDigits = 0;
this.sizeState = SIZE_DIGITS;
}
if (this.sizeDigits > 0 || d !== 0) {
if (this.sizeDigits === 16) {
this.fail("Chunk size overflow", "INVALID_CHUNK_SIZE");
return false;
}
this.sizeDigits++;
this.chunkSize = this.chunkSize * 16 + d;
}
} else if (state === SIZE_AFTER) {
if (c === 0x0d) this.sizeState = SIZE_CR;
else if (c === 0x3b) this.sizeState = SIZE_EXT;
else if (c === 0x0a && v7) {
this.fail("Missing expected CR after chunk size", "CR_EXPECTED");
return false;
} else {
this.fail("Invalid character in chunk size", "INVALID_CHUNK_SIZE");
return false;
}
} else if (state === SIZE_EXT) {
if (c === 0x20 || c === 0x0d) {
this.fail("Invalid character in chunk extensions", "STRICT");
return false;
}
this.sizeState = SIZE_EXT_NAME;
continue;
} else if (state === SIZE_EXT_NAME) {
if (c === 0x3d) this.sizeState = SIZE_EXT_VALUE;
else if (c === 0x3b) this.sizeState = SIZE_EXT;
else if (c === 0x0d) this.sizeState = SIZE_CR;
else if (c === 0x0a && v7) {
this.fail("Missing expected CR after chunk extension name", "CR_EXPECTED");
return false;
} else if (TOKEN_BYTES[c] !== 1) {
this.fail("Invalid character in chunk extensions name", "STRICT");
return false;
}
} else if (state === SIZE_EXT_VALUE) {
if (c === 0x22) this.sizeState = SIZE_EXT_QUOTED;
// llhttp 8 goes on with the chunk-size node after a value's ';'.
else if (c === 0x3b) this.sizeState = v7 ? SIZE_EXT : SIZE_AFTER;
else if (c === 0x0d) this.sizeState = SIZE_CR;
else if (c === 0x0a && v7) {
this.fail("Missing expected CR after chunk extension value", "CR_EXPECTED");
return false;
} else if (TOKEN_BYTES[c] !== 1) {
this.fail("Invalid character in chunk extensions value", "STRICT");
return false;
}
} else if (state === SIZE_EXT_QUOTED) {
if (c === 0x22) this.sizeState = SIZE_EXT_QUOTED_DONE;
else if (c === 0x5c && v7) this.sizeState = SIZE_EXT_PAIR;
else if (!isQuotedByte(c)) {
this.fail("Invalid character in chunk extensions quoted value", "STRICT");
return false;
}
} else if (state === SIZE_EXT_PAIR) {
if (!isQuotedPairByte(c)) {
this.fail("Invalid quoted-pair in chunk extensions quoted value", "STRICT");
return false;
}
this.sizeState = SIZE_EXT_QUOTED;
} else if (state === SIZE_EXT_QUOTED_DONE) {
if (c === 0x3b) this.sizeState = SIZE_EXT;
else if (c === 0x0d) this.sizeState = SIZE_CR;
else if (c === 0x0a && v7) {
this.fail("Missing expected CR after chunk extension value", "CR_EXPECTED");
return false;
} else {
this.fail("Invalid character in chunk extensions quote value", "STRICT");
return false;
}
} else {
// SIZE_CR: llhttp 9 needs the LF; llhttp 8's lenient mode skips
// whatever byte follows the CR.
if (c !== 0x0a && v7) {
this.fail("Expected LF after chunk size", "STRICT");
return false;
}
this.buf = buf.subarray(i + 1);
this.sizeState = SIZE_FIRST;
return true;
}
i++;
}
this.buf = Buffer.alloc(0);
return false;
}
line(): Buffer | null {
const idx = this.buf.indexOf(CRLF);
if (idx === -1) return null;
@@ -941,7 +1111,12 @@ class ResponseParser {
const text = line.toString("latin1");
const m = /^HTTP\/1\.([01]) (\d{3})(?: (.*))?$/.exec(text);
if (m === null) {
this.fail("Response does not match the HTTP/1.1 protocol", "INVALID_CONSTANT");
const v7 = this.client.__profile.major >= 7;
if (!text.startsWith("HTTP/")) {
this.fail(v7 ? "Expected HTTP/, RTSP/ or ICE/" : "Expected HTTP/", "INVALID_CONSTANT");
} else {
this.fail(v7 ? "Invalid status code" : "Invalid response status", "INVALID_STATUS");
}
return;
}
this.versionMinor = Number(m[1]);
@@ -1055,55 +1230,15 @@ class ResponseParser {
if (!this.complete()) return;
}
} else if (this.state === 3) {
// Chunk size line: hex digits, then optional whitespace and
// extensions, then CRLF. It is read byte by byte as it arrives.
if (this.buf.length === 0) {
if (this.more()) continue;
// Chunk size line; a partial one continues with the next arrival.
if (!this.chunkSizeLine()) {
if (this.state === 3 && this.more()) continue;
return;
}
let i = 0;
let done = false;
for (; i < this.buf.length && !done; i++) {
const c = this.buf[i]!;
if (this.chunkCR) {
if (c !== 0x0a) {
this.fail("Expected LF after chunk size", "STRICT");
return;
}
done = true;
} else if (c === 0x0d) {
if (this.chunkDigits === 0) {
this.fail("Invalid character in chunk size", "INVALID_CHUNK_SIZE");
return;
}
this.chunkCR = true;
} else if (this.chunkExtension) {
continue;
} else if (hexValue(c) >= 0) {
this.chunkSize = this.chunkSize * 16 + hexValue(c);
this.chunkDigits++;
if (this.chunkSize > Number.MAX_SAFE_INTEGER) {
this.fail("Chunk size overflow", "INVALID_CHUNK_SIZE");
return;
}
} else if (this.chunkDigits > 0 && (c === 0x3b || c === 0x20 || c === 0x09)) {
this.chunkExtension = true;
} else {
this.fail("Invalid character in chunk size", "INVALID_CHUNK_SIZE");
return;
}
}
this.buf = this.buf.subarray(i);
if (!done) continue;
const size = this.chunkSize;
this.chunkSize = 0;
this.chunkDigits = 0;
this.chunkExtension = false;
this.chunkCR = false;
if (size === 0) {
if (this.chunkSize === 0) {
this.state = 5;
} else {
this.remaining = size;
this.remaining = this.chunkSize;
this.state = 4;
}
} else if (this.state === 4) {
@@ -8,7 +8,7 @@ export declare namespace DiagnosticsChannel {
completed: boolean;
method?: string;
path: string;
headers: string;
headers: string | string[];
addHeader(key: string, value: string): Request;
}
interface Response {
@@ -38,6 +38,12 @@ export interface ResponseData {
const defaultProfile: Profile = { major: 7 };
export class Dispatcher extends EventEmitter {
// An explicit constructor: the implicit one forwards its arguments to
// EventEmitter's options-taking constructor, which stays a runtime fence.
constructor() {
super();
}
dispatch(_options: DispatchOptions, _handler: DispatchHandlers): boolean {
throw new Error("not implemented");
}
+277 -55
View File
@@ -2,14 +2,16 @@
// program-installed global dispatcher) it is the runtime's own fetch, which
// implements the same WHATWG algorithm. With one, the request goes through
// that dispatcher (its connector, lookup and pool), following undici's
// fetch for request headers, redirects and errors; the response body is
// buffered into a native Response.
// fetch for request headers, redirects and errors. The promise resolves
// when the response headers arrive, and the Response body streams from the
// connection with undici's back-pressure (see dispatchOnce).
//
// Known gaps: a Response built here reports `url` as "" and `redirected` as
// false (the runtime's Response has no settable URL yet), and https requests
// offer `gzip, deflate` rather than undici's `br, gzip, deflate` (the runtime
// has no Brotli decoder).
import { gunzipSync, inflateSync } from "node:zlib";
import type { Transform } from "node:stream";
import * as zlib from "node:zlib";
import type { Dispatcher } from "./dispatcher.ts";
import { getGlobalDispatcher, hasCustomGlobalDispatcher } from "./api.ts";
import type { DispatchHandlers, Profile } from "./types.ts";
@@ -115,7 +117,10 @@ interface RawResponse {
status: number;
statusText: string;
headers: string[];
body: Buffer;
/** null when the response has no body to expose: a null-body status or
* HEAD (its bytes, if any, are read and dropped), or a redirect fetch
* follows or rejects (the request is aborted, closing its connection). */
body: ReadableStream<Uint8Array> | null;
}
const redirectStatuses = [301, 302, 303, 307, 308];
@@ -125,7 +130,7 @@ export function undiciFetch(
input: string | URL,
init: UndiciRequestInit | undefined,
createGlobal: () => Dispatcher,
_profile: Profile,
profile: Profile,
): Promise<Response> {
const explicit = init !== undefined ? init.dispatcher : undefined;
if (explicit === undefined && !hasCustomGlobalDispatcher()) {
@@ -139,7 +144,7 @@ export function undiciFetch(
});
}
const dispatcher = explicit ?? getGlobalDispatcher(createGlobal);
return fetchThrough(dispatcher, input, init ?? {});
return fetchThrough(dispatcher, input, init ?? {}, profile);
}
function normalizeMethod(method: string): string {
@@ -153,6 +158,7 @@ async function fetchThrough(
dispatcher: Dispatcher,
input: string | URL,
init: UndiciRequestInit,
profile: Profile,
): Promise<Response> {
let url = typeof input === "string" ? new URL(input) : new URL(input.href);
let method = normalizeMethod(init.method ?? "GET");
@@ -197,7 +203,7 @@ async function fetchThrough(
let raw: RawResponse;
try {
raw = await dispatchOnce(dispatcher, url, method, pairs, body, signal);
raw = await dispatchOnce(dispatcher, url, method, pairs, body, signal, redirectMode, profile);
} catch (err) {
if (signal !== null && signal.aborted) throw abortReason(signal);
throw new TypeError("fetch failed", { cause: err });
@@ -243,15 +249,11 @@ async function fetchThrough(
continue;
}
const nullBody = nullBodyStatuses.includes(raw.status) || method === "HEAD";
return new Response(
nullBody ? null : decode(raw.body, responseHeaders.get("content-encoding")),
{
status: raw.status,
statusText: raw.statusText,
headers: responseHeaders,
},
);
return new Response(raw.body, {
status: raw.status,
statusText: raw.statusText,
headers: responseHeaders,
});
}
}
@@ -324,20 +326,28 @@ function headerListOf(
return list;
}
function decode(body: Buffer, encoding: string | null): Buffer {
if (encoding === null || body.length === 0) return body;
let out = body;
const codings = encoding
.toLowerCase()
.split(",")
.map((c) => c.trim())
.reverse();
/** undici 7's limit on a response's content codings, applied in both
* profiles: undici 5 has none, and builds one decoder per listed coding. */
const maxContentEncodings = 5;
/** Whether every coding has a decoder; otherwise the body passes through
* as sent. */
function decodable(codings: string[]): boolean {
if (codings.length === 0) return false;
for (const coding of codings) {
if (coding === "gzip" || coding === "x-gzip") out = gunzipSync(out);
else if (coding === "deflate") out = inflateSync(out);
else return body;
if (coding !== "gzip" && coding !== "x-gzip" && coding !== "deflate") return false;
}
return out;
return true;
}
/** A decoder for one coding. undici 7 decodes with Z_SYNC_FLUSH, which
* accepts a truncated stream; undici 5 uses zlib's defaults. */
function decoderFor(coding: string, profile: Profile): Transform {
const options =
profile.major >= 7
? { flush: zlib.constants.Z_SYNC_FLUSH, finishFlush: zlib.constants.Z_SYNC_FLUSH }
: {};
return coding === "deflate" ? zlib.createInflate(options) : zlib.createGunzip(options);
}
function abortReason(signal: AbortSignal): Error {
@@ -347,6 +357,16 @@ function abortReason(signal: AbortSignal): Error {
: new DOMException("This operation was aborted", "AbortError");
}
/** Unread bytes held beside the response stream before the connection
* pauses: Node's Readable highWaterMark, which undici's fetch body uses. */
const bodyHighWaterMark = 64 * 1024;
/** Dispatches one request and resolves when its response headers arrive.
* The body streams as undici's fetch reads one: up to bodyHighWaterMark
* unread bytes wait beside the stream, past that the connection (or the
* last decoder) pauses until the reader pulls again; cancelling the stream
* aborts the request, and an error after the headers errors the stream
* with TypeError('terminated'). */
function dispatchOnce(
dispatcher: Dispatcher,
url: URL,
@@ -354,50 +374,252 @@ function dispatchOnce(
headers: string[],
body: Buffer | null,
signal: AbortSignal | null,
redirectMode: "follow" | "manual" | "error",
profile: Profile,
): Promise<RawResponse> {
return new Promise<RawResponse>((resolve, reject) => {
let status = 0;
let statusText = "";
let rawHeaders: string[] = [];
const chunks: Buffer[] = [];
let settled = false;
let resolved = false;
let done = false;
let dump = false;
let abortFn: ((err?: Error) => void) | null = null;
const onAbort = (): void => {
// The response body: its stream controller, the unread bytes beside it,
// and how to restart the paused producer.
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
let hasBody = false;
const queue: Buffer[] = [];
let queued = 0;
let ended = false;
let closed = false;
let onDrain: (() => void) | null = null;
// The decoders, first-applied last; their listeners hold this scope, so
// releaseDecoders drops them once the body ends, fails or is cancelled.
let decoding = false;
let decoders: Transform[] = [];
let first: Transform | null = null;
let encodedBytes = 0;
let resumeConnection: (() => void) | null = null;
const abortRequest = (err: Error): void => {
const abort = abortFn;
if (abort !== null && signal !== null) abort(abortReason(signal));
if (abort !== null && !done) abort(err);
};
const onAbort = (): void => {
if (signal === null) return;
const reason = abortReason(signal);
// After the headers undici 7 errors the body with the signal's
// reason; undici 5 aborts its fetch controller without one, so the
// body gets the controller's default AbortError.
errorBody(
profile.major >= 7 ? reason : new DOMException("The operation was aborted.", "AbortError"),
);
abortRequest(reason);
};
const stopListening = (): void => {
if (signal !== null) signal.removeEventListener("abort", onAbort);
};
if (signal !== null) signal.addEventListener("abort", onAbort);
const finish = (err: Error | null): void => {
if (settled) return;
settled = true;
if (signal !== null) signal.removeEventListener("abort", onAbort);
if (err !== null) reject(err);
else resolve({ status, statusText, headers: rawHeaders, body: Buffer.concat(chunks) });
const releaseDecoders = (): void => {
const chain = decoders;
decoders = [];
first = null;
for (const decoder of chain) {
decoder.removeAllListeners();
decoder.destroy();
}
};
const closeBody = (): void => {
closed = true;
controller!.close();
stopListening();
};
const errorBody = (err: unknown): void => {
if (!hasBody || closed) return;
closed = true;
queue.length = 0;
queued = 0;
controller!.error(err);
stopListening();
releaseDecoders();
};
const endBody = (): void => {
if (closed) return;
ended = true;
if (queue.length === 0) closeBody();
};
/** Takes a chunk; false when the producer should pause until onDrain. */
const pushBody = (chunk: Buffer): boolean => {
if (closed) return true;
if (queue.length === 0 && (controller!.desiredSize ?? 0) > 0) {
controller!.enqueue(new Uint8Array(chunk));
return true;
}
queue.push(chunk);
queued += chunk.length;
return queued < bodyHighWaterMark;
};
const pullBody = (): void => {
if (closed) return;
const next = queue.shift();
if (next !== undefined) {
queued -= next.length;
controller!.enqueue(new Uint8Array(next));
}
if (queue.length === 0 && ended) {
closeBody();
return;
}
const drain = onDrain;
if (drain !== null && queued < bodyHighWaterMark) {
onDrain = null;
drain();
}
};
const cancelBody = (reason: unknown): void => {
if (closed) return;
closed = true;
queue.length = 0;
queued = 0;
stopListening();
releaseDecoders();
abortRequest(
reason instanceof Error
? reason
: new DOMException("The operation was aborted.", "AbortError"),
);
};
const failBody = (err: Error): void => {
errorBody(new TypeError("terminated", { cause: err }));
abortRequest(err);
};
const handler: DispatchHandlers = {
onConnect: (abort: (err?: Error) => void): void => {
abortFn = abort;
if (signal !== null && signal.aborted) abort(abortReason(signal));
},
onHeaders: (
statusCode: number,
raw: Buffer[],
_resume: () => void,
text: string,
): boolean => {
onHeaders: (statusCode: number, raw: Buffer[], resume: () => void, text: string): boolean => {
if (statusCode < 200) return true;
status = statusCode;
statusText = text;
rawHeaders = [];
for (const b of raw) rawHeaders.push(b.toString("latin1"));
resolved = true;
resumeConnection = resume;
const rawHeaders: string[] = [];
let location = false;
let encoding: string | null = null;
for (let i = 0; i + 1 < raw.length; i += 2) {
const name = raw[i]!.toString("latin1");
const value = raw[i + 1]!.toString("latin1");
rawHeaders.push(name, value);
const lower = name.toLowerCase();
if (lower === "location") location = true;
else if (lower === "content-encoding")
encoding = encoding === null ? value : `${encoding}, ${value}`;
}
const follows =
redirectStatuses.includes(statusCode) && location && redirectMode !== "manual";
if (follows || nullBodyStatuses.includes(statusCode) || method === "HEAD") {
dump = true;
stopListening();
resolve({ status: statusCode, statusText: text, headers: rawHeaders, body: null });
// A redirect that is followed or rejected never reads its body:
// abort the request so the connection is released instead of
// draining a body that may never end.
if (follows) abortRequest(new DOMException("The operation was aborted.", "AbortError"));
return true;
}
const codings =
encoding === null
? []
: encoding
.toLowerCase()
.split(",")
.map((c) => c.trim());
if (codings.length > maxContentEncodings) {
dump = true;
stopListening();
reject(
new Error(
`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`,
),
);
abortRequest(new DOMException("The operation was aborted.", "AbortError"));
return true;
}
hasBody = true;
const stream = new ReadableStream<Uint8Array>({
start: (c: ReadableStreamDefaultController<Uint8Array>): void => {
controller = c;
},
pull: (): void => pullBody(),
cancel: (reason: unknown): void => cancelBody(reason),
});
if (decodable(codings)) {
// Decoders run last-applied coding first, piped into each other;
// the last one feeds the body.
decoding = true;
let last: Transform | null = null;
for (let i = codings.length - 1; i >= 0; i--) {
const decoder = decoderFor(codings[i]!, profile);
decoder.on("error", (err: Error) => failBody(err));
if (last === null) first = decoder;
else last.pipe(decoder);
decoders.push(decoder);
last = decoder;
}
const output = last!;
output.on("data", (chunk: Buffer) => {
if (!pushBody(chunk)) {
output.pause();
onDrain = (): void => {
output.resume();
};
}
});
output.on("end", () => {
releaseDecoders();
endBody();
});
first!.on("drain", () => resume());
}
resolve({ status: statusCode, statusText: text, headers: rawHeaders, body: stream });
return true;
},
onData: (chunk: Buffer): boolean => {
chunks.push(chunk);
return true;
if (dump || !hasBody || closed) return true;
if (decoding) {
const decoder = first;
if (decoder === null) return true;
encodedBytes += chunk.length;
return decoder.write(chunk);
}
if (pushBody(chunk)) return true;
onDrain = resumeConnection;
return false;
},
onComplete: (_trailers: string[] | null): void => {
done = true;
if (!hasBody) {
stopListening();
return;
}
// zlib rejects an empty stream; undici ends such a body unread.
const decoder = first;
if (decoder !== null && encodedBytes > 0) {
decoder.end();
} else {
releaseDecoders();
endBody();
}
},
onError: (err: Error): void => {
done = true;
if (!resolved) {
stopListening();
reject(err);
return;
}
errorBody(new TypeError("terminated", { cause: err }));
},
onComplete: (_trailers: string[] | null): void => finish(null),
onError: (err: Error): void => finish(err),
};
dispatcher.dispatch(
{
+34 -8
View File
@@ -32,8 +32,10 @@ export class Request {
error: Error | null;
abortFn: ((err?: Error) => void) | null;
handler: DispatchHandlers;
/** The request as diagnostics_channel subscribers see it: one boxed
* value per request, so its identity is stable across publications. */
profile: Profile;
/** The request as diagnostics_channel subscribers see it: one untyped
* object per request, so its identity is stable across publications.
* The request keeps its completed and headers fields current. */
diagnostic: unknown;
constructor(origin: string, opts: DispatchOptions, handler: DispatchHandlers, profile: Profile) {
@@ -91,6 +93,7 @@ export class Request {
}
this.completed = false;
this.profile = profile;
this.aborted = false;
this.idempotent = opts.idempotent ?? (method === "HEAD" || method === "GET");
this.blocking = opts.blocking ?? (profile.major >= 7 ? method !== "HEAD" : false);
@@ -131,18 +134,29 @@ export class Request {
}
diagnosticView(): unknown {
let view = this.diagnostic;
if (view === null) {
view = {
if (this.diagnostic === null) {
// addHeader adds a header as undici's Request.addHeader does (the
// tracing instrumentations' header injection).
const view: any = {
origin: this.origin,
completed: this.completed,
method: this.method,
path: this.path,
headers: this.headers,
headers: this.diagnosticHeaders(),
addHeader: (key: string, value: string): unknown => {
this.addHeader(key, value);
return view;
},
};
this.diagnostic = view;
}
return view;
return this.diagnostic;
}
/** The diagnostics view's headers: undici 7 publishes its header array,
* undici 5 the raw block. */
diagnosticHeaders(): string | string[] {
return this.profile.major >= 7 ? this.headerPairs.slice() : this.headers;
}
/** The DiagnosticsChannel.Request view's raw header block. */
@@ -155,7 +169,9 @@ export class Request {
}
addHeader(key: string, value: string): Request {
this.headerPairs.push(key, value);
processHeader(this, key, value, this.profile);
const view: any = this.diagnostic;
if (view !== null) view.headers = this.diagnosticHeaders();
return this;
}
@@ -207,6 +223,8 @@ export class Request {
onComplete(trailers: Buffer[]): void {
this.completed = true;
const view: any = this.diagnostic;
if (view !== null) view.completed = true;
if (channels.trailers.hasSubscribers)
channels.trailers.publish({ request: this.diagnosticView(), trailers });
if (this.handler.onComplete !== undefined) {
@@ -227,10 +245,18 @@ export class Request {
function processHeader(request: Request, key: string, val: string, profile: Profile): void {
const lower = key.toLowerCase();
// undici 7 checks the name and every value before the special cases;
// undici 5 checks the first Host value before storing it. Either way a
// CR or LF in a value never reaches the request head.
if (profile.major >= 7) {
if (!tokenRegExp.test(lower)) throw new InvalidArgumentError("invalid header key");
if (headerCharRegex.test(val)) throw new InvalidArgumentError(`invalid ${key} header`);
}
if (lower === "host") {
// The client writes the Host value into the request head as given.
if (headerCharRegex.test(val)) throw new InvalidArgumentError(`invalid ${key} header`);
if (request.host === null) {
if (headerCharRegex.test(val)) throw new InvalidArgumentError(`invalid ${key} header`);
request.host = val;
return;
}
@@ -35,7 +35,9 @@ function onTick(): void {
}
}
fastTimers.length = len;
// An idle tick stops; the next timer schedules a new one.
if (fastTimers.length !== 0) refreshTimeout();
else refreshTick = null;
}
function refreshTimeout(): void {
@@ -28,6 +28,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.dynTier ? { dynTier: true } : {}),
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBufferNum ? { dynBufferNum: true } : {}),
...(features.builtinValues ? { builtinValues: true } : {}),
@@ -37,6 +38,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
bigint: features.bigint,
searchParams: features.searchParams,
qs: features.qs,
utilPromisify: features.utilPromisify,
dynBuiltinClass: features.dynBuiltinClass,
dynBuiltinStream: features.dynBuiltinStream,
parseArgs: features.parseArgs,
@@ -46,6 +48,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
http2: features.http2,
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
watch: features.fsWatch,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
@@ -42,6 +42,7 @@ export async function compilePackedLibrary(
bigint: options.bigint ?? false,
searchParams: options.searchParams ?? false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -51,6 +52,7 @@ export async function compilePackedLibrary(
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
+20 -4
View File
@@ -86,6 +86,7 @@ export const DYN_HANDLE_TAG_NUM: Record<string, number> = {
child: 16,
fileHandle: 17,
dir: 44,
dgramSocket: 62,
cryptoHash: 52,
cryptoHmac: 53,
};
@@ -96,6 +97,15 @@ export const DYN_HANDLE_TAG_NUM: Record<string, number> = {
const DYN_HANDLE_BOX_FN: Record<string, string> = {
cryptoHash: "scr_crypto_hash_to_dyn",
cryptoHmac: "scr_crypto_hash_to_dyn",
dgramSocket: "scr_dgram_to_dyn",
};
/** Handle kinds whose box holds a unit-owned wrapper rather than the
* handle itself (a dgram socket's box carries its untyped listeners):
* the unit's own function unwraps it, with the tag check and TypeError
* of scr_dyn_handle_unbox. */
const DYN_HANDLE_UNBOX_FN: Record<string, string> = {
dgramSocket: "scr_dgram_from_dyn",
};
export const DYN_KIND = {
@@ -2492,11 +2502,17 @@ export class LlDyn {
// TypeError on any other kind or tag).
const h = DYN_HANDLE_KINDS.get(t.kind);
if (h) {
host.declare(`declare ptr @scr_dyn_handle_unbox(ptr, i32, ptr, ptr)`);
const r = B.tmp();
B.line(
`${r} = call ptr @scr_dyn_handle_unbox(ptr %d, i32 ${DYN_HANDLE_TAG_NUM[t.kind]}, ptr %path, ptr ${want})`,
);
const unbox = DYN_HANDLE_UNBOX_FN[t.kind];
if (unbox) {
host.declare(`declare ptr @${unbox}(ptr, ptr, ptr)`);
B.line(`${r} = call ptr @${unbox}(ptr %d, ptr %path, ptr ${want})`);
} else {
host.declare(`declare ptr @scr_dyn_handle_unbox(ptr, i32, ptr, ptr)`);
B.line(
`${r} = call ptr @scr_dyn_handle_unbox(ptr %d, i32 ${DYN_HANDLE_TAG_NUM[t.kind]}, ptr %path, ptr ${want})`,
);
}
B.terminate(`ret ptr ${r}`);
break;
}
@@ -27,7 +27,7 @@ import { findMapEntryFusions } from "./map-entry-fusion.js";
import { findLocalStackUnions } from "./stack-unions.js";
import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js";
import { findLocalUnionStorage } from "./local-union-storage.js";
import { INT32_RANGE, analyzeIntegerRanges } from "../../ir/integer-ranges.js";
import { INT32_RANGE, analyzeIntegerRangeFacts } from "../../ir/integer-ranges.js";
import { findBytesBounds } from "./bytes-bounds.js";
import { findInt32Locals } from "./int32-locals.js";
import { findIntegerViews } from "./integer-views.js";
@@ -59,6 +59,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const B = new BlockBuilder();
emitter.B = B;
emitter.currentFunctionName = fn.name;
emitter.currentTier = fn.tier;
// Fiber switches restore the active exception cell before control
// returns to a synchronous frame (emitPendingCheck).
B.excCellInvariant = fn.async !== true && fn.generator === undefined;
@@ -220,7 +221,9 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.localUnionStorage = new Map();
emitter.walkBorrows = emitter.walkBorrowsByFunction.get(fn.name) ?? new Set();
const slotFacts = emitter.int32Slots.facts(fn.name);
emitter.integerRanges = analyzeIntegerRanges(numericFn, slotFacts);
const rangeFacts = analyzeIntegerRangeFacts(numericFn, slotFacts);
emitter.integerRanges = rangeFacts.ranges;
emitter.integerLoopRanges = rangeFacts.loopRanges;
emitter.int32Elements = slotFacts.elements ?? null;
emitter.bytesBounds = findBytesBounds(numericFn, emitter.integerRanges);
// Debug builds keep every number local a double for the debugger's
@@ -1,6 +1,7 @@
/* LlEmitter's module-level output: module parts, helper definitions, library declarations and FFI callback definitions. */
import { emitWasmModules } from "./wasm-modules.js";
import { type LlEmitter, ffiCallbackDummyLl, llStrBytes } from "./emitter.js";
import { JS_IC_WORDS } from "./js-ic.js";
import {
mangleAsyncSpawn,
mangleClassObj,
@@ -559,6 +560,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
out.push(
`@sc_dyn_read_ic_${site} = internal ${tl}global { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr } zeroinitializer`,
);
for (let site = 0; site < emitter.jsIcSiteCount; site++)
out.push(`@sc_js_ic_${site} = internal ${tl}global [${JS_IC_WORDS} x i64] zeroinitializer`);
}
for (const table of emitter.constantNumericTables.values()) {
const n = table.values.length;
@@ -56,7 +56,9 @@ import {
SPECULATIVE_RANGE,
emitSafeIntegerCheck,
emitSpeculativeInteger,
planProvenIntegerLoop,
planSpeculativeLoop,
type ProvenLoopPlan,
speculativeReads,
speculativeWriteTarget,
} from "./speculative-integers.js";
@@ -1802,7 +1804,9 @@ export function emitLoopCopies(
)
: null;
if (!plan) {
emitCopy(exit);
const proven = allowed ? planProvenLoop(emitter, loop) : null;
if (proven) emitProvenLoop(emitter, proven, exit, emitCopy);
else emitCopy(exit);
return;
}
const B = emitter.B;
@@ -1881,7 +1885,7 @@ export function emitLoopCopies(
const leave = B.newLabel("spec.leave");
emitCopy(leave);
B.startBlock(leave);
emitter.writeBackSpeculativeGlobals(deferred);
emitter.writeBackDeferred(deferred);
B.br(exit);
}
} finally {
@@ -1890,10 +1894,91 @@ export function emitLoopCopies(
}
}
/** A proven integer loop (speculative-integers.ts) for this loop, or null.
* A shadowed local's double is stale until the loop is left, which only
* the loop's exit, a return and an exception do: the loop is planned only
* where an exception leaves the function (no enclosing try or finally, no
* coroutine frame), so no handler reads the stale double. */
function planProvenLoop(
emitter: LlEmitter,
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" }>,
): ProvenLoopPlan | null {
if (
loop.kind !== "for" ||
!emitter.speculativeLoopsEnabled ||
emitter.speculation !== null ||
emitter.speculationResume !== null ||
emitter.tryStack.length > 0 ||
emitter.finallyStack.length > 0 ||
emitter.currentCoro !== null
)
return null;
const cursors = emitter.integerLoopRanges.get(loop);
if (!cursors) return null;
return planProvenIntegerLoop(
loop,
cursors,
(id) => emitter.currentLocals.has(id) && emitter.speculativeLocal(id),
(id) => emitter.integerLoopBindings.has(id) || emitter.int32Locals.has(id),
(e) => {
const range = emitter.integerRanges.get(e);
return (
range !== undefined &&
range !== null &&
range.min >= -Number.MAX_SAFE_INTEGER &&
range.max <= Number.MAX_SAFE_INTEGER
);
},
);
}
/** Emit a proven integer loop: its shadowed locals start from their
* doubles (integers in their loop ranges on entry), its planned writes
* compute integers into the shadows only, and leaving the loop through its
* exit writes each double back. */
function emitProvenLoop(
emitter: LlEmitter,
plan: ProvenLoopPlan,
exit: string,
emitCopy: (exit: string) => void,
): void {
const B = emitter.B;
for (const [id, range] of plan.locals) {
const shadow = B.slot();
B.entryAllocas.push(
`${shadow} = alloca i64 ; integer ${emitter.currentLocals.get(id)?.name ?? id}`,
);
const value = B.tmp(),
integer = B.tmp();
B.line(`${value} = load double, ptr ${emitter.binding(id).slot}`);
B.line(`${integer} = fptosi double ${value} to i64`);
B.line(`store i64 ${integer}, ptr ${shadow}`);
emitter.integerLoopBindings.set(id, { slot: shadow, type: "i64", signed: true, range });
}
const deferred = new Set<string>();
for (const id of plan.locals.keys()) deferred.add(id);
emitter.speculation = { writes: plan.writes, resume: new Map(), deferred };
try {
const leave = B.newLabel("integer.leave");
emitCopy(leave);
B.startBlock(leave);
emitter.writeBackDeferred(deferred);
B.br(exit);
} finally {
emitter.speculation = null;
for (const id of plan.locals.keys()) emitter.integerLoopBindings.delete(id);
}
}
/** A speculated write in the fast copy: compute the value as a safe
* integer, store it to the shadow and its exact double to the local.
* Any failed check re-executes the statement in the ordinary copy. */
export function emitSpeculativeWrite(emitter: LlEmitter, s: IrStmt, resume: string): void {
* Any failed check re-executes the statement in the ordinary copy. A
* proven integer loop's write (no `resume`) has nothing to check. */
export function emitSpeculativeWrite(
emitter: LlEmitter,
s: IrStmt,
resume: string | undefined,
): void {
const B = emitter.B;
const target = speculativeWriteTarget(s)!;
const binding = emitter.integerLoopBindings.get(target.localId)!;
@@ -1901,7 +1986,7 @@ export function emitSpeculativeWrite(emitter: LlEmitter, s: IrStmt, resume: stri
let bails = false;
emitter.declare("declare i1 @llvm.expect.i1(i1, i1)");
const bail = (condition: string): void => {
if (condition === "false") return;
if (condition === "false" || resume === undefined) return;
bails = true;
const expected = B.tmp();
B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${condition}, i1 false)`);
@@ -1930,7 +2015,7 @@ export function emitSpeculativeWrite(emitter: LlEmitter, s: IrStmt, resume: stri
B.line(`${biased} = add i64 ${result}, ${Number.MAX_SAFE_INTEGER}`);
B.line(`${outside} = icmp ugt i64 ${biased}, ${2 * Number.MAX_SAFE_INTEGER}`);
bail(outside);
} else result = emitSpeculativeInteger(emitter, target.value, bail, reads);
} else result = emitSpeculativeInteger(emitter, target.value, bail, reads, resume === undefined);
// Speculable values read only numbers past this point.
if (emitter.frames.pop()!.length > 0)
throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary");
@@ -1945,8 +2030,8 @@ export function emitSpeculativeWrite(emitter: LlEmitter, s: IrStmt, resume: stri
const done = B.newLabel("spec.done");
B.br(done);
B.startBlock(failed);
emitter.writeBackSpeculativeGlobals(deferred);
B.br(resume);
emitter.writeBackDeferred(deferred);
B.br(resume!);
B.startBlock(done);
}
+22 -3
View File
@@ -1,5 +1,6 @@
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
import { JsIcSites } from "../../ir/js-ic.js";
import { type ArraySearchKind } from "./array-search.js";
import { emitStringInputs } from "./string-lifetimes.js";
import { emitThreadSlotsIntrinsic } from "./thread-slots.js";
@@ -919,6 +920,12 @@ export class LlEmitter {
readonly recordCloneShapes = new Set<string>();
readonly tracedShapes: Set<string>;
readonly tracedUnions: Set<string>;
/** Inline-cache sites of dynamic-tier functions (js-ic.ts): one
* zero-initialized ScrJsIc per site (scr_js.h). */
jsIcSiteCount = 0;
private jsIcSitesMemo: JsIcSites | undefined;
/** The tier of the function being emitted. */
currentTier: IrFunction["tier"] = undefined;
/** The class graph (buildClassGraph): preorder numbering, hierarchy
* membership, virtual slot lists. */
readonly classMeta: Map<string, LlClassMeta>;
@@ -1037,6 +1044,8 @@ export class LlEmitter {
* not a constructor (where they change), not async or a generator. */
finalFieldBorrows = false;
integerRanges: IntegerRanges = new Map();
/** The current function's bounded for-loop cursors (integer-ranges.ts). */
integerLoopRanges: ReadonlyMap<IrStmt, ReadonlyMap<string, IntegerRange>> = new Map();
/** Number arrays of the current function whose every present element is
* an int32 (Int32[] contracts), or null when the module has none. */
int32Elements: ((arr: IrExpr) => boolean) | null = null;
@@ -2687,7 +2696,8 @@ export class LlEmitter {
return emitLoopCopies(this, loop, allowed, exit, emitCopy);
}
writeBackSpeculativeGlobals(deferred: Set<string>): void {
/** Store the double of each deferred shadow back to its binding. */
writeBackDeferred(deferred: Set<string>): void {
const B = this.B;
for (const id of deferred) {
const integer = B.tmp(),
@@ -2766,7 +2776,7 @@ export class LlEmitter {
}
return this.globalWriters.get(id) ?? new Set<string>();
}
private emitSpeculativeWrite(s: IrStmt, resume: string): void {
private emitSpeculativeWrite(s: IrStmt, resume: string | undefined): void {
return emitSpeculativeWrite(this, s, resume);
}
@@ -4460,7 +4470,7 @@ export class LlEmitter {
this.B.startBlock(resume.label);
}
if (this.speculation?.writes.has(s)) {
this.emitSpeculativeWrite(s, this.speculation.resume.get(s)!);
this.emitSpeculativeWrite(s, this.speculation.resume.get(s));
return;
}
const previous = this.B.debugLocation;
@@ -5555,6 +5565,15 @@ export class LlEmitter {
return emitExpr(this, e);
}
/** The helper classification of this module's inline-cache sites. */
get jsIcSites(): JsIcSites {
return (this.jsIcSitesMemo ??= new JsIcSites(this.mod));
}
jsIcSite(): string {
return `@sc_js_ic_${this.jsIcSiteCount++}`;
}
/** A fresh read-site cache for one constant-key dyn read. */
dynReadIcSite(): string {
return `@sc_dyn_read_ic_${this.dynReadIcSites++}`;
@@ -53,6 +53,7 @@ import {
inputSuffixWrites,
} from "./borrowed-inputs.js";
import type { Writes } from "./reference-writes.js";
import { emitJsIcCall, jsIcSiteOf } from "./js-ic.js";
import { emitClassMembership, emitDowncastGuard } from "./narrow-guards.js";
export function emitCallExpr(
@@ -88,6 +89,12 @@ export function emitCallExpr(
case "call": {
const callee = host.fnByName.get(e.callee);
if (!callee) throw new InternalCompilerError(`llvm emitter bug: unknown callee ${e.callee}`);
if (!plainCall && !awaitEntry && !resultSlot && !stackResult) {
// A property helper called from dynamic-tier code is an inline
// cache site (two-tier design S4).
const site = jsIcSiteOf(host, e);
if (site) return emitJsIcCall(host, e, callee, site);
}
if (callee.fastRead === "index" && !plainCall && !awaitEntry && !resultSlot) {
const indexed = emitNumericIndexRead(host, e, callee);
if (indexed) return indexed;
@@ -28,6 +28,7 @@ import { elemAccess, vAdapters } from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { emitUnionWiden } from "./expr-records.js";
import { emitJsIcMethod, jsIcMethods } from "./js-ic.js";
import { DYN_BUFFER_NUM_METHODS } from "../../ir/runtime-features.js";
export function emitDynamicExpr(
@@ -207,10 +208,16 @@ export function emitDynamicExpr(
// pending exception.
const recv = emitBorrowedInput(host, e.recv);
host.declare(`declare ptr @scr_dyn_prepare_method(ptr, ptr)`);
const prepared = B.tmp();
B.line(
`${prepared} = call ptr @scr_dyn_prepare_method(ptr ${recv.name}, ptr ${host.cstr(e.method)})`,
);
let prepared: string;
if (jsIcMethods(host)) {
// Dynamic-tier code reads the method through the site's cache.
prepared = emitJsIcMethod(host, e, recv.name);
} else {
prepared = B.tmp();
B.line(
`${prepared} = call ptr @scr_dyn_prepare_method(ptr ${recv.name}, ptr ${host.cstr(e.method)})`,
);
}
host.own({ name: prepared, type: { kind: "dyn" } });
host.emitPendingCheck();
const args = e.args.map((a) => emitBorrowedInput(host, a));
+779
View File
@@ -0,0 +1,779 @@
/* Inline-cache lowering of the dynamic tier (two-tier design stage S4;
* runtime scr_js_object.c, sites ir/js-ic.ts).
*
* A property site in a dynamic-tier function owns one zeroed thread-local
* ScrJsIc record (`@sc_js_ic_N`). The code at the site is fixed:
*
* load ordinary object whose shape is entry 0's own-property shape:
* read entry `slot` inline unless it is an accessor; otherwise
* scr_js_load_probe (polymorphic, prototype hits, misses)
* store ordinary object whose shape is entry 0's replace shape, outside
* an intact parse region, writable data entry: replace inline;
* otherwise scr_js_store_probe (adds, polymorphic, misses)
* keyed dense array element at an integral number key: inline;
* otherwise scr_js_keyed_probe (string keys of ordinary objects)
* method dynInvoke's method read: scr_js_load_probe
*
* A probe that cannot answer from plain data leaves everything untouched,
* and the site calls its helper (or the generic runtime entry), which
* defines the operation's semantics; nothing is ever patched.
*
* Layout facts (64-bit only; 32-bit targets keep the helper calls):
* ScrDyn kind +8; v.obj {len +16, entries +32, region +72,
* shape +80}; v.arr {len +16, items +32, presence +56};
* v.num +16
* ScrDynEntry 48 bytes: value +16, accessor bit 0 of +40, writable +41
* ScrJsIc entries of 40 bytes {shape, target, holder, holder_shape,
* slot}; 168 bytes in all */
import { BOOL, DYN, F64, STRING, type IrExpr, type IrFunction } from "../../ir/ir.js";
import { jsIcMethodSites, type JsIcSite } from "../../ir/js-ic.js";
import { mangleBorrowedFunction } from "../mangle.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { DYN_KIND } from "./dyn.js";
import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js";
import { JS_BITS } from "./jsval-encoding.js";
import { jsImmediates } from "./jsval.js";
type Host = LlvmEmitterContext;
/** Words of one ScrJsIc record. */
export const JS_IC_WORDS = 21;
const ENTRY_STRIDE = 48;
/** The cache site a call is, when it is emitted in a dynamic-tier function
* of a 64-bit target. */
export function jsIcSiteOf(host: Host, e: ExprOf<"call">): JsIcSite | null {
if (!jsImmediates(host) || host.currentTier !== "dynamic") return null;
if (host.int32Abis.has(e.callee)) return null;
return host.jsIcSites.site(e.callee);
}
/** Whether method dispatch in the current function reads through a cache. */
export function jsIcMethods(host: Host): boolean {
return (
jsImmediates(host) &&
host.currentTier === "dynamic" &&
jsIcMethodSites(host.currentFunctionName)
);
}
function gep(host: Host, base: string, offset: number): string {
const p = host.B.tmp();
host.B.line(`${p} = getelementptr inbounds i8, ptr ${base}, i64 ${offset}`);
return p;
}
function load(host: Host, type: string, base: string, offset: number): string {
const v = host.B.tmp();
host.B.line(`${v} = load ${type}, ptr ${offset === 0 ? base : gep(host, base, offset)}`);
return v;
}
/** Branches to `yes` when the dyn cell `d` has kind `kind`. */
function kindIs(host: Host, d: string, kind: number, yes: string, no: string): void {
const k = load(host, "i32", d, 8);
const is = host.B.tmp();
host.B.line(`${is} = icmp eq i32 ${k}, ${kind}`);
host.B.condBr(is, yes, no);
}
/* How many cache entries a site tests inline, and whether those tests
* cover prototype hits. Measured (two-tier S4): testing all four entries
* inline made polymorphic reads 7-10% faster (the design prototype's W2/W3)
* and monomorphic method code no faster, for 65-225 more bytes per site;
* the prototype hit made method-heavy code 3-6% faster for 15-90 bytes.
* So a site tests entry 0, own or prototype, and its probe does the rest.
* (0 makes every access probe: with a runtime built with SCR_JS_VERIFY,
* that checks every cached shape against its object.) */
const INLINE_WAYS: number = 1;
const INLINE_PROTO = true;
/** The address of entry `slot` of object `d`'s entries. */
function entryAt(host: Host, d: string, slot: string): string {
const B = host.B;
const entries = load(host, "ptr", d, 32);
const offset = B.tmp();
const entry = B.tmp();
B.line(`${offset} = mul i64 ${slot}, ${ENTRY_STRIDE}`);
B.line(`${entry} = getelementptr inbounds i8, ptr ${entries}, i64 ${offset}`);
return entry;
}
/** Branches to `miss` when the entry at `entry` is an accessor. */
function dataEntry(host: Host, entry: string, miss: string): void {
const B = host.B;
const flags = load(host, "i8", entry, 40);
const accessor = B.tmp();
const isAccessor = B.tmp();
B.line(`${accessor} = and i8 ${flags}, 1`);
B.line(`${isAccessor} = icmp ne i8 ${accessor}, 0`);
const data = B.newLabel("ic.data");
B.condBr(isAccessor, miss, data);
B.startBlock(data);
}
/** The inline part of a load site, entered for an ordinary object `d`:
* tests the first `ways` cache entries (own properties, and prototype hits
* when `proto`); each hit converts the (borrowed) value with `result` and
* branches to `join`, recording its phi input. Anything else branches to
* `miss`. */
function inlineLoad(
host: Host,
d: string,
ic: string,
ways: number,
proto: boolean,
result: (cell: string) => string,
incoming: string[],
join: string,
miss: string,
): void {
const B = host.B;
if (ways === 0) {
B.br(miss);
return;
}
const shape = load(host, "ptr", d, 80);
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${shape}, null`);
let next = B.newLabel("ic.w");
B.condBr(present, next, miss);
for (let way = 0; way < ways; way++) {
const base = way * 40;
B.startBlock(next);
next = way + 1 < ways ? B.newLabel("ic.w") : miss;
const cached = load(host, "ptr", ic, base);
const same = B.tmp();
B.line(`${same} = icmp eq ptr ${shape}, ${cached}`);
const matched = B.newLabel("ic.m");
B.condBr(same, matched, next);
B.startBlock(matched);
const target = load(host, "ptr", ic, base + 8);
const own = B.tmp();
B.line(`${own} = icmp eq ptr ${target}, null`);
const ownLabel = B.newLabel("ic.own");
const protoLabel = proto ? B.newLabel("ic.proto") : miss;
B.condBr(own, ownLabel, protoLabel);
const hit = (holder: string): void => {
const slot = load(host, "i64", ic, base + 32);
const entry = entryAt(host, holder, slot);
dataEntry(host, entry, miss);
const value = load(host, "ptr", entry, 16);
const out = result(value);
incoming.push(`[ ${out}, %${B.currentLabel()} ]`);
B.br(join);
};
B.startBlock(ownLabel);
hit(d);
if (!proto) continue;
B.startBlock(protoLabel);
const holder = load(host, "ptr", ic, base + 16);
const prototype = load(host, "ptr", d, 96);
const linked = B.tmp();
B.line(`${linked} = icmp eq ptr ${prototype}, ${holder}`);
const isLinked = B.newLabel("ic.pl");
B.condBr(linked, isLinked, miss);
B.startBlock(isLinked);
const holderObject = B.newLabel("ic.po");
kindIs(host, holder, DYN_KIND.OBJ, holderObject, miss);
B.startBlock(holderObject);
const holderShape = load(host, "ptr", holder, 80);
const current = B.tmp();
B.line(`${current} = icmp eq ptr ${holderShape}, ${target}`);
const holderHit = B.newLabel("ic.ph");
B.condBr(current, holderHit, miss);
B.startBlock(holderHit);
hit(holder);
}
}
/** The inline part of a store site that replaces an own data property,
* entered for an ordinary object `d` outside an intact parse region:
* tests the first `ways` cache entries; a hit calls `store` with the
* address of the entry's value word, which must end its block by
* branching to the site's join. */
function inlineReplace(
host: Host,
d: string,
ic: string,
ways: number,
store: (slot: string) => void,
miss: string,
): void {
const B = host.B;
if (ways === 0) {
B.br(miss);
return;
}
const shape = load(host, "ptr", d, 80);
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${shape}, null`);
let next = B.newLabel("ic.w");
B.condBr(present, next, miss);
for (let way = 0; way < ways; way++) {
const base = way * 40;
B.startBlock(next);
next = way + 1 < ways ? B.newLabel("ic.w") : miss;
const cached = load(host, "ptr", ic, base);
const target = load(host, "ptr", ic, base + 8);
const same = B.tmp();
const replace = B.tmp();
const ok = B.tmp();
B.line(`${same} = icmp eq ptr ${shape}, ${cached}`);
B.line(`${replace} = icmp eq ptr ${target}, ${shape}`);
B.line(`${ok} = and i1 ${same}, ${replace}`);
const matched = B.newLabel("ic.m");
B.condBr(ok, matched, next);
B.startBlock(matched);
const slot = load(host, "i64", ic, base + 32);
const entry = entryAt(host, d, slot);
const flags = load(host, "i8", entry, 40);
const writable = load(host, "i8", entry, 41);
const accessor = B.tmp();
const data = B.tmp();
const canWrite = B.tmp();
const writes = B.tmp();
B.line(`${accessor} = and i8 ${flags}, 1`);
B.line(`${data} = icmp eq i8 ${accessor}, 0`);
B.line(`${canWrite} = icmp ne i8 ${writable}, 0`);
B.line(`${writes} = and i1 ${data}, ${canWrite}`);
const hit = B.newLabel("ic.st");
B.condBr(writes, hit, miss);
B.startBlock(hit);
store(gep(host, entry, 16));
}
}
/** A property value (a dyn cell, `owned` or borrowed from its entry) as
* the site's result type, owned. A jsval result reads a number cell as an
* immediate, so arithmetic on it takes no reference and a later store can
* update an unshared number cell in place; any other cell is carried under
* the cell tag. */
function asResult(host: Host, cell: string, e: IrExpr, owned: boolean | string): string {
const B = host.B;
/** Retains the cell unless the caller owns it. */
const hold = (): string => {
if (owned === true) return cell;
if (owned === false) return host.retainFrame(cell, DYN);
const borrowed = B.newLabel("icr.b");
const done = B.newLabel("icr.h");
const from = B.currentLabel();
B.condBr(owned, done, borrowed);
B.startBlock(borrowed);
const retained = host.retainFrame(cell, DYN);
const retainedEnd = B.currentLabel();
B.br(done);
B.startBlock(done);
const held = B.tmp();
B.line(`${held} = phi ptr [ ${cell}, %${from} ], [ ${retained}, %${retainedEnd} ]`);
return held;
};
if (e.type.kind !== "jsval") return hold();
const number = B.newLabel("icr.num");
const other = B.newLabel("icr.cell");
const join = B.newLabel("icr.j");
kindIs(host, cell, DYN_KIND.NUM, number, other);
B.startBlock(number);
const d = load(host, "double", cell, 16);
const raw = B.tmp();
const nan = B.tmp();
const bits = B.tmp();
B.line(`${raw} = bitcast double ${d} to i64`);
B.line(`${nan} = fcmp uno double ${d}, ${d}`);
B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`);
const immediate = B.tmp();
B.line(`${immediate} = inttoptr i64 ${bits} to ptr`);
if (owned === true) {
host.releaseFrame(cell, DYN);
} else if (owned !== false) {
const release = B.newLabel("icr.r");
const released = B.newLabel("icr.rd");
B.condBr(owned, release, released);
B.startBlock(release);
host.releaseFrame(cell, DYN);
B.br(released);
B.startBlock(released);
}
const numberEnd = B.currentLabel();
B.br(join);
B.startBlock(other);
const held = hold();
const cellBits = B.tmp();
const word = B.tmp();
const tagged = B.tmp();
B.line(`${cellBits} = ptrtoint ptr ${held} to i64`);
B.line(`${word} = or i64 ${cellBits}, ${JS_BITS.cellTag}`);
B.line(`${tagged} = inttoptr i64 ${word} to ptr`);
const otherEnd = B.currentLabel();
B.br(join);
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi ptr [ ${immediate}, %${numberEnd} ], [ ${tagged}, %${otherEnd} ]`);
return out;
}
/** The helper call of a site's slow path, with the site's evaluated
* (borrowed) arguments. Ordinary parameters take a reference; borrowed
* ones lend it. Returns the owned result, or "" for a void helper. */
function helperCall(host: Host, e: ExprOf<"call">, callee: IrFunction, args: LlValue[]): string {
const B = host.B;
const borrowed = host.callLifetimes.borrowed.get(e.callee);
const argList = args
.map((a, i) => {
const type = callee.params[i]!.type;
const value = borrowed?.has(i) ? a.name : host.retainFrame(a.name, a.type);
return `${host.llType(type)} ${value}`;
})
.join(", ");
const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}`;
// The helper is the site's cold path: keep its body out of line.
if (e.type.kind === "void") {
B.line(`call void ${target}(${argList}) noinline cold`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
return "";
}
let t = B.tmp();
B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList}) noinline cold`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (borrowed && host.borrowedReturns.has(e.callee)) t = host.retainFrame(t, e.type);
return t;
}
function keyString(host: Host, key: string, e: IrExpr): string {
return host.emitExpr({ kind: "strLit", value: key, type: STRING, loc: e.loc }).name;
}
/** Emits a call to a classified helper as a cache site. */
export function emitJsIcCall(
host: Host,
e: ExprOf<"call">,
callee: IrFunction,
site: JsIcSite,
): LlValue {
switch (site.kind) {
case "get":
return emitGet(host, e, callee, site.key);
case "set":
return emitSet(host, e, callee, site.key);
case "keyed":
return emitKeyed(host, e, callee);
}
}
function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string): LlValue {
const B = host.B;
const receiver = emitBorrowedInput(host, e.args[0]!);
const name = keyString(host, key, e);
const ic = host.jsIcSite();
const object = B.newLabel("icg.obj");
const probe = B.newLabel("icg.probe");
const slow = B.newLabel("icg.slow");
const join = B.newLabel("icg.j");
const incoming: string[] = [];
if (key === "length") {
// An array's length is its element count (the generic read answers
// it before any property table).
const array = B.newLabel("icg.arr");
const other = B.newLabel("icg.other");
kindIs(host, receiver.name, DYN_KIND.ARR, array, other);
B.startBlock(array);
const len = load(host, "i64", receiver.name, 16);
const count = B.tmp();
B.line(`${count} = uitofp i64 ${len} to double`);
incoming.push(`[ ${numberResult(host, count, e)}, %${B.currentLabel()} ]`);
B.br(join);
B.startBlock(other);
}
// Inline hits (borrowed values) and probe hits (owned ones) share one
// conversion to the result type.
const hit = B.newLabel("icg.hit");
const hits: string[] = [];
kindIs(host, receiver.name, DYN_KIND.OBJ, object, probe);
B.startBlock(object);
inlineLoad(host, receiver.name, ic, INLINE_WAYS, INLINE_PROTO, (cell) => cell, hits, hit, probe);
B.startBlock(probe);
host.declare(`declare ptr @scr_js_load_probe(ptr, ptr, ptr)`);
const probed = B.tmp();
B.line(`${probed} = call ptr @scr_js_load_probe(ptr ${receiver.name}, ptr ${name}, ptr ${ic})`);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${probed}, null`);
const probeEnd = B.currentLabel();
B.condBr(found, hit, slow);
B.startBlock(hit);
const cell = B.tmp();
const owned = B.tmp();
const ownedIn = hits.map((h) => h.replace(/^\[ [^,]+,/, "[ false,"));
B.line(`${cell} = phi ptr ${[...hits, `[ ${probed}, %${probeEnd} ]`].join(", ")}`);
B.line(`${owned} = phi i1 ${[...ownedIn, `[ true, %${probeEnd} ]`].join(", ")}`);
{
const out = asResult(host, cell, e, owned);
incoming.push(`[ ${out}, %${B.currentLabel()} ]`);
}
B.br(join);
B.startBlock(slow);
const result = helperCall(host, e, callee, [receiver]);
incoming.push(`[ ${result}, %${B.currentLabel()} ]`);
B.br(join);
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi ptr ${incoming.join(", ")}`);
return host.own({ name: out, type: e.type });
}
/** A number as the site's result type: an immediate for a jsval result,
* a fresh cell (as the generic read makes) for a dyn one. */
function numberResult(host: Host, d: string, e: IrExpr): string {
const B = host.B;
if (e.type.kind === "jsval") {
// Counts are never NaN: the bits are the word.
const bits = B.tmp();
const word = B.tmp();
B.line(`${bits} = bitcast double ${d} to i64`);
B.line(`${word} = inttoptr i64 ${bits} to ptr`);
return word;
}
host.declare(`declare ptr @scr_dyn_new_num(double)`);
const cell = B.tmp();
B.line(`${cell} = call ptr @scr_dyn_new_num(double ${d})`);
return cell;
}
function emitSet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string): LlValue {
const B = host.B;
const receiver = emitBorrowedInput(host, e.args[0]!);
// A value the jsval pass left as `js.toDyn(x)` is stored from the jsval
// itself: a number replaces the value of an unshared number cell in
// place instead of boxing a new one.
const stored = e.args[1]!;
const word =
stored.kind === "libCall" && stored.fn === "js.toDyn"
? host.emitExpr(stored.args[0]!).name
: null;
const value = word === null ? emitBorrowedInput(host, stored) : null;
const name = keyString(host, key, e);
const ic = host.jsIcSite();
const object = B.newLabel("ics.obj");
const probe = B.newLabel("ics.probe");
const slow = B.newLabel("ics.slow");
const join = B.newLabel("ics.j");
// The cell the probe and the helper store when the value is a jsval;
// the statement's frame releases it.
let boxedSlot: string | null = null;
if (word !== null) {
boxedSlot = B.slot();
B.entryAllocas.push(`${boxedSlot} = alloca ptr`);
B.line(`store ptr null, ptr ${boxedSlot}`);
host.ownSlot(boxedSlot, DYN);
}
kindIs(host, receiver.name, DYN_KIND.OBJ, object, probe);
B.startBlock(object);
const region = load(host, "ptr", receiver.name, 72);
const outside = B.tmp();
B.line(`${outside} = icmp eq ptr ${region}, null`);
const unshared = B.newLabel("ics.r");
B.condBr(outside, unshared, probe);
B.startBlock(unshared);
host.declare(`declare ptr @scr_js_to_dyn_borrow(ptr)`);
inlineReplace(
host,
receiver.name,
ic,
INLINE_WAYS,
(slot) => {
const old = B.tmp();
B.line(`${old} = load ptr, ptr ${slot}`);
if (word === null) {
B.line(`store ptr ${host.retainHeap(value!.name, DYN)}, ptr ${slot}`);
host.releaseHeap(old, DYN);
B.br(join);
return;
}
const bits = B.tmp();
const isDouble = B.tmp();
B.line(`${bits} = ptrtoint ptr ${word} to i64`);
B.line(`${isDouble} = icmp ult i64 ${bits}, ${JS_BITS.limit}`);
const number = B.newLabel("ics.dbl");
const box = B.newLabel("ics.box");
B.condBr(isDouble, number, box);
B.startBlock(number);
const oldKind = load(host, "i32", old, 8);
const oldRc = load(host, "i64", old, 0);
const isNumber = B.tmp();
const unshared = B.tmp();
const inPlace = B.tmp();
B.line(`${isNumber} = icmp eq i32 ${oldKind}, ${DYN_KIND.NUM}`);
B.line(`${unshared} = icmp eq i64 ${oldRc}, 1`);
B.line(`${inPlace} = and i1 ${isNumber}, ${unshared}`);
const update = B.newLabel("ics.inplace");
B.condBr(inPlace, update, box);
B.startBlock(update);
const d = B.tmp();
B.line(`${d} = bitcast i64 ${bits} to double`);
B.line(`store double ${d}, ptr ${gep(host, old, 16)}`);
B.br(join);
B.startBlock(box);
const cell = B.tmp();
B.line(`${cell} = call ptr @scr_js_to_dyn_borrow(ptr ${word})`);
B.line(`store ptr ${cell}, ptr ${slot}`);
host.releaseHeap(old, DYN);
B.br(join);
},
probe,
);
B.startBlock(probe);
let cell: string;
if (word !== null) {
cell = B.tmp();
B.line(`${cell} = call ptr @scr_js_to_dyn_borrow(ptr ${word})`);
B.line(`store ptr ${cell}, ptr ${boxedSlot}`);
} else {
cell = value!.name;
}
host.declare(`declare zeroext i1 @scr_js_store_probe(ptr, ptr, ptr, ptr)`);
const done = B.tmp();
B.line(
`${done} = call zeroext i1 @scr_js_store_probe(ptr ${receiver.name}, ptr ${name}, ptr ${cell}, ptr ${ic})`,
);
B.condBr(done, join, slow);
B.startBlock(slow);
helperCall(host, e, callee, [receiver, { name: cell, type: DYN }]);
B.br(join);
B.startBlock(join);
return { name: "", type: e.type };
}
/** A keyed site's key when it is a static number boxed for the helper
* (`dynFrom(n)`, or `dynFrom(u)` of a local union with a number arm): the
* site indexes with the double and boxes the key only on a miss. */
interface NumberKey {
/** i1: whether `value` holds the key (always for a static number). */
present: string;
value: string;
/** Emits the key's cell (+1), exactly as the helper argument boxes it. */
box: () => string;
}
function numberKey(host: Host, key: IrExpr): NumberKey | null {
if (key.kind !== "dynFrom" || key.liveRef !== undefined || key.fnName !== undefined) return null;
const source = key.value;
if (source.type.kind === "f64") {
const value = host.emitExpr(source).name;
// Request the converter where the boxing it replaces did: converters
// are numbered in request order.
const conv = host.dyn.toDynHelper(source.type);
return {
present: "true",
value,
box: () => {
const cell = host.B.tmp();
host.B.line(`${cell} = call ptr @${conv}(double ${value})`);
return cell;
},
};
}
if (source.type.kind !== "union" || source.kind !== "varRef") return null;
const unionId = source.type.unionId;
const tag = host.unionsById.get(unionId)?.arms.findIndex((arm) => arm.kind === "f64") ?? -1;
if (tag < 0) return null;
const B = host.B;
const present = host.emitExpr({
kind: "unionIsTag",
unionId,
tag,
negated: false,
value: source,
type: BOOL,
loc: key.loc,
}).name;
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca double`);
B.line(`store double 0.0, ptr ${slot}`);
const number = B.newLabel("ick.k");
const done = B.newLabel("ick.kd");
B.condBr(present, number, done);
B.startBlock(number);
// Temps of a conditional block are released in it.
host.frames.push([]);
const d = host.emitExpr({
kind: "unionNarrow",
unionId,
tag,
value: source,
type: F64,
loc: key.loc,
});
B.line(`store double ${d.name}, ptr ${slot}`);
host.releaseTemps(host.frames.pop()!);
B.br(done);
B.startBlock(done);
const value = B.tmp();
B.line(`${value} = load double, ptr ${slot}`);
return {
present,
value,
box: () => {
// The local is a plain read: boxing it again evaluates nothing else.
host.frames.push([]);
const boxed = host.emitExpr(key);
host.toFrame(boxed);
host.releaseTemps(host.frames.pop()!);
return boxed.name;
},
};
}
function emitKeyed(host: Host, e: ExprOf<"call">, callee: IrFunction): LlValue {
const B = host.B;
const receiver = emitBorrowedInput(host, e.args[0]!);
const keyExpr = e.args[1]!;
const numeric = numberKey(host, keyExpr);
const key = numeric === null ? emitBorrowedInput(host, keyExpr) : null;
const ic = host.jsIcSite();
const array = B.newLabel("ick.arr");
const number = B.newLabel("ick.num");
const probe = B.newLabel("ick.probe");
const slow = B.newLabel("ick.slow");
const join = B.newLabel("ick.j");
const incoming: string[] = [];
// The boxed key of the miss paths when the site indexes with a double;
// the statement's frame releases it.
let keySlot: string | null = null;
if (numeric !== null) {
keySlot = B.slot();
B.entryAllocas.push(`${keySlot} = alloca ptr`);
B.line(`store ptr null, ptr ${keySlot}`);
host.ownSlot(keySlot, DYN);
}
kindIs(host, receiver.name, DYN_KIND.ARR, array, probe);
B.startBlock(array);
let index: string;
if (numeric === null) {
kindIs(host, key!.name, DYN_KIND.NUM, number, probe);
B.startBlock(number);
index = load(host, "double", key!.name, 16);
} else {
B.condBr(numeric.present, number, probe);
B.startBlock(number);
index = numeric.value;
}
const len = load(host, "i64", receiver.name, 16);
const i = B.tmp();
const back = B.tmp();
const integral = B.tmp();
const inRange = B.tmp();
const nonNegative = B.tmp();
const ok1 = B.tmp();
const ok2 = B.tmp();
// fptoui of a negative or out-of-range double is poison: test the
// double's range before trusting the integer.
B.line(`${nonNegative} = fcmp oge double ${index}, 0.0`);
const lenD = B.tmp();
B.line(`${lenD} = uitofp i64 ${len} to double`);
B.line(`${inRange} = fcmp olt double ${index}, ${lenD}`);
B.line(`${ok1} = and i1 ${nonNegative}, ${inRange}`);
const ranged = B.newLabel("ick.rng");
B.condBr(ok1, ranged, probe);
B.startBlock(ranged);
B.line(`${i} = fptoui double ${index} to i64`);
B.line(`${back} = uitofp i64 ${i} to double`);
B.line(`${integral} = fcmp oeq double ${back}, ${index}`);
const presence = load(host, "ptr", receiver.name, 56);
const dense = B.tmp();
B.line(`${dense} = icmp eq ptr ${presence}, null`);
B.line(`${ok2} = and i1 ${integral}, ${dense}`);
const hit = B.newLabel("ick.hit");
B.condBr(ok2, hit, probe);
B.startBlock(hit);
const items = load(host, "ptr", receiver.name, 32);
const itemPtr = B.tmp();
B.line(`${itemPtr} = getelementptr inbounds ptr, ptr ${items}, i64 ${i}`);
const item = B.tmp();
B.line(`${item} = load ptr, ptr ${itemPtr}`);
const itemEnd = B.currentLabel();
const shared = B.newLabel("ick.shared");
B.br(shared);
B.startBlock(probe);
let keyCell: string;
if (numeric === null) {
keyCell = key!.name;
} else {
keyCell = numeric.box();
B.line(`store ptr ${keyCell}, ptr ${keySlot}`);
}
host.declare(`declare ptr @scr_js_keyed_probe(ptr, ptr, ptr)`);
const probed = B.tmp();
B.line(
`${probed} = call ptr @scr_js_keyed_probe(ptr ${receiver.name}, ptr ${keyCell}, ptr ${ic})`,
);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${probed}, null`);
const probeEnd = B.currentLabel();
B.condBr(found, shared, slow);
B.startBlock(shared);
{
const cell = B.tmp();
const owned = B.tmp();
B.line(`${cell} = phi ptr [ ${item}, %${itemEnd} ], [ ${probed}, %${probeEnd} ]`);
B.line(`${owned} = phi i1 [ false, %${itemEnd} ], [ true, %${probeEnd} ]`);
const out = asResult(host, cell, e, owned);
incoming.push(`[ ${out}, %${B.currentLabel()} ]`);
}
B.br(join);
B.startBlock(slow);
const result = helperCall(host, e, callee, [receiver, { name: keyCell, type: DYN }]);
incoming.push(`[ ${result}, %${B.currentLabel()} ]`);
B.br(join);
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi ptr ${incoming.join(", ")}`);
return host.own({ name: out, type: e.type });
}
/** dynInvoke's method read on an ordinary object through the site's
* cache, else scr_dyn_prepare_method. Returns the owned method (or NULL,
* which scr_dyn_invoke_prepared answers with the full dispatch). */
export function emitJsIcMethod(host: Host, e: ExprOf<"dynInvoke">, receiver: string): string {
const B = host.B;
const method = e.method;
const ic = host.jsIcSite();
const name = keyString(host, method, e);
host.declare(`declare ptr @scr_js_load_probe(ptr, ptr, ptr)`);
host.declare(`declare ptr @scr_dyn_prepare_method(ptr, ptr)`);
const object = B.newLabel("icm.obj");
const probe = B.newLabel("icm.probe");
const slow = B.newLabel("icm.slow");
const join = B.newLabel("icm.j");
const incoming: string[] = [];
kindIs(host, receiver, DYN_KIND.OBJ, object, probe);
B.startBlock(object);
inlineLoad(
host,
receiver,
ic,
INLINE_WAYS,
INLINE_PROTO,
(cell) => host.retainFrame(cell, DYN),
incoming,
join,
probe,
);
B.startBlock(probe);
const probed = B.tmp();
B.line(`${probed} = call ptr @scr_js_load_probe(ptr ${receiver}, ptr ${name}, ptr ${ic})`);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${probed}, null`);
incoming.push(`[ ${probed}, %${B.currentLabel()} ]`);
B.condBr(found, join, slow);
B.startBlock(slow);
const prepared = B.tmp();
B.line(
`${prepared} = call ptr @scr_dyn_prepare_method(ptr ${receiver}, ptr ${host.cstr(method)}) cold`,
);
incoming.push(`[ ${prepared}, %${B.currentLabel()} ]`);
B.br(join);
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi ptr ${incoming.join(", ")}`);
return out;
}
+148 -12
View File
@@ -205,6 +205,105 @@ function call(host: Host, ret: string, fn: string, args: [string, string][]): st
return r;
}
/** `x % y` on two doubles: an integer remainder when both are integers of
* magnitude below 2^31 and y is nonzero (the result keeps the dividend's
* sign, so a zero result of a negative dividend, including -0, is -0),
* else frem (libm's fmod). */
function remainder(host: Host, x: string, y: string): string {
const B = host.B;
host.declare(`declare double @llvm.fabs.f64(double)`);
const ax = B.tmp();
const ay = B.tmp();
const smallX = B.tmp();
const smallY = B.tmp();
const small = B.tmp();
B.line(`${ax} = call double @llvm.fabs.f64(double ${x})`);
B.line(`${ay} = call double @llvm.fabs.f64(double ${y})`);
B.line(`${smallX} = fcmp olt double ${ax}, 2147483648.0`);
B.line(`${smallY} = fcmp olt double ${ay}, 2147483648.0`);
B.line(`${small} = and i1 ${smallX}, ${smallY}`);
const ints = B.newLabel("js.rem.i");
const exact = B.newLabel("js.rem.x");
const float = B.newLabel("js.rem.f");
const join = B.newLabel("js.rem.j");
B.condBr(small, ints, float);
B.startBlock(ints);
const xi = B.tmp();
const yi = B.tmp();
const xb = B.tmp();
const yb = B.tmp();
const xInt = B.tmp();
const yInt = B.tmp();
const nonzero = B.tmp();
const both = B.tmp();
const ok = B.tmp();
B.line(`${xi} = fptosi double ${x} to i64`);
B.line(`${yi} = fptosi double ${y} to i64`);
B.line(`${xb} = sitofp i64 ${xi} to double`);
B.line(`${yb} = sitofp i64 ${yi} to double`);
B.line(`${xInt} = fcmp oeq double ${xb}, ${x}`);
B.line(`${yInt} = fcmp oeq double ${yb}, ${y}`);
B.line(`${nonzero} = icmp ne i64 ${yi}, 0`);
B.line(`${both} = and i1 ${xInt}, ${yInt}`);
B.line(`${ok} = and i1 ${both}, ${nonzero}`);
B.condBr(ok, exact, float);
B.startBlock(exact);
const r = B.tmp();
const rd = B.tmp();
const xBits = B.tmp();
const negative = B.tmp();
const zero = B.tmp();
const negZero = B.tmp();
const intResult = B.tmp();
B.line(`${r} = srem i64 ${xi}, ${yi}`);
B.line(`${rd} = sitofp i64 ${r} to double`);
B.line(`${xBits} = bitcast double ${x} to i64`);
B.line(`${negative} = icmp slt i64 ${xBits}, 0`);
B.line(`${zero} = icmp eq i64 ${r}, 0`);
B.line(`${negZero} = and i1 ${negative}, ${zero}`);
B.line(`${intResult} = select i1 ${negZero}, double -0.0, double ${rd}`);
const exactEnd = B.currentLabel();
B.br(join);
B.startBlock(float);
const fr = B.tmp();
B.line(`${fr} = frem double ${x}, ${y}`);
const floatEnd = B.currentLabel();
B.br(join);
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi double [ ${intResult}, %${exactEnd} ], [ ${fr}, %${floatEnd} ]`);
return out;
}
const BITWISE_OPS: Readonly<Record<string, string>> = {
"&": "and",
"|": "or",
"^": "xor",
"<<": "shl",
">>": "ashr",
">>>": "lshr",
};
/** A double's ToInt32 when its magnitude is below 2^63: truncation toward
* zero, then the low 32 bits; branches to `slow` otherwise (NaN and the
* infinities included). */
function int32Operand(host: Host, d: string, slow: string): string {
const B = host.B;
host.declare(`declare double @llvm.fabs.f64(double)`);
const magnitude = B.tmp();
const small = B.tmp();
B.line(`${magnitude} = call double @llvm.fabs.f64(double ${d})`);
B.line(`${small} = fcmp olt double ${magnitude}, 9223372036854775808.0`);
const ok = B.newLabel("js.int");
B.condBr(small, ok, slow);
B.startBlock(ok);
const wide = B.tmp();
const i = B.tmp();
B.line(`${wide} = fptosi double ${d} to i64`);
B.line(`${i} = trunc i64 ${wide} to i32`);
return i;
}
const ARITH_OPS: Readonly<Record<string, string>> = {
"-": "fsub",
"*": "fmul",
@@ -334,8 +433,8 @@ export function emitJsLibCall(host: Host, e: LibCallExpr): LlValue {
(slow) => {
const x = a.number(slow);
const y = b.number(slow);
const r = B.tmp();
B.line(`${r} = ${inst} double ${x}, ${y}`);
const r = op === "%" ? remainder(host, x, y) : B.tmp();
if (op !== "%") B.line(`${r} = ${inst} double ${x}, ${y}`);
return boxDouble(host, r);
},
runtime,
@@ -410,17 +509,54 @@ export function emitJsLibCall(host: Host, e: LibCallExpr): LlValue {
return { name: out, type: e.type };
}
case "js.bitwise": {
const a = host.emitExpr(e.args[0]!).name;
const b = host.emitExpr(e.args[1]!).name;
const op = literalOp(e.args[2]!);
const inst = op !== null ? BITWISE_OPS[op] : undefined;
if (inst === undefined) {
const a = host.emitExpr(e.args[0]!).name;
const b = host.emitExpr(e.args[1]!).name;
const opArg = host.emitExpr(e.args[2]!).name;
const r = call(host, "ptr", "scr_js_bitwise", [
["ptr", a],
["ptr", b],
["ptr", opArg],
]);
const out = jsval(r);
host.emitPendingCheck();
return out;
}
// Two numbers of magnitude below 2^63: ToInt32 is truncation and
// wrapping, so the operation runs on i32; anything else (NaN, the
// infinities, larger magnitudes) takes the runtime.
const a = operand(host, e.args[0]!);
const b = operand(host, e.args[1]!);
const opArg = host.emitExpr(e.args[2]!).name;
const r = call(host, "ptr", "scr_js_bitwise", [
["ptr", a],
["ptr", b],
["ptr", opArg],
]);
const out = jsval(r);
host.emitPendingCheck();
return out;
const out = withSlowPath(
host,
"ptr",
(slow) => {
const x = int32Operand(host, a.number(slow), slow);
const y = int32Operand(host, b.number(slow), slow);
const r = B.tmp();
const d = B.tmp();
if (op === "<<" || op === ">>" || op === ">>>") {
const count = B.tmp();
B.line(`${count} = and i32 ${y}, 31`);
B.line(`${r} = ${inst} i32 ${x}, ${count}`);
} else {
B.line(`${r} = ${inst} i32 ${x}, ${y}`);
}
B.line(`${d} = ${op === ">>>" ? "uitofp" : "sitofp"} i32 ${r} to double`);
return boxDouble(host, d);
},
() =>
call(host, "ptr", "scr_js_bitwise", [
["ptr", a.word()],
["ptr", b.word()],
["ptr", opArg],
]),
true,
);
return jsval(out);
}
default:
throw new InternalCompilerError(`llvm emitter bug: unknown jsval builtin ${e.fn}`);
@@ -82,6 +82,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"util.inherits": "scr_util_inherits",
"util.deprecate": "scr_util_deprecate",
"util.callbackify": "scr_util_callbackify",
"util.promisifyDyn": "scr_util_promisify",
"util.debuglog": "scr_util_debuglog",
"util.typeIs": "scr_dyn_util_type_is",
"util.typeValue": "scr_dyn_util_type_value",
@@ -951,6 +952,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.iterPack": "scr_dyn_iter_pack",
"dyn.destructureRest": "scr_dyn_destructure_rest",
"dyn.iterator": "scr_dyn_iterator",
"dyn.asyncIteratorOpen": "scr_dyn_async_iterator_open",
"dyn.asyncIteratorWasSync": "scr_dyn_async_iterator_was_sync",
"dyn.arrayFromIterator": "scr_dyn_array_from_iterator",
"dyn.iteratorResult": "scr_dyn_iterator_result",
"dyn.iteratorCanStep": "scr_dyn_iterator_can_step",
@@ -1572,6 +1575,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
// The wrappers schedule warnings and callbacks on the tick queue.
"util.deprecate",
"util.callbackify",
"util.promisifyDyn",
"util.debuglog",
"worker.new",
"worker.parentPort",
@@ -2,6 +2,7 @@ import { expect, test } from "vitest";
import { F64, type IrExpr, type IrStmt } from "../../ir/ir.js";
import {
isSpeculableValue,
planProvenIntegerLoop,
planSpeculativeLoop,
speculativeReads,
} from "./speculative-integers.js";
@@ -194,3 +195,74 @@ test("loops that only count and compare keep one copy", () => {
]);
expect(planSpeculativeLoop(scaled as never, all)?.locals.has("n")).toBe(true);
});
test("bounded cursors with proven writes are shadowed in a proven integer loop", () => {
// for (i = 0; i < n; i++) { if (a < b) run = run + 1; else run = 0;
// if (run > longest) longest = run; }
const sum = bin("+", ref("run"), num(1));
const step = assign("run", sum);
const reset = assign("run", num(0));
const copy = assign("longest", ref("run"));
const forLoop = (body: IrStmt[]): IrStmt => ({
kind: "for",
init: null,
cond: bin("<", ref("i"), ref("n")),
update: assign("i", bin("+", ref("i"), num(1))),
body,
loc,
});
const branch = (then: IrStmt[], else_: IrStmt[] | null): IrStmt => ({
kind: "if",
cond: bin("<", ref("a"), ref("b")),
then,
else_,
loc,
});
const body = [branch([step], [reset]), branch([copy], null)];
const range = { min: 0, max: 100 };
const cursors = new Map([
["run", range],
["longest", range],
]);
const plan = planProvenIntegerLoop(forLoop(body) as never, cursors, all, () => false, all)!;
expect([...plan.locals]).toEqual([
["run", range],
["longest", range],
]);
expect(plan.writes).toEqual(new Set([step, reset, copy]));
// A binding the loop does not bound keeps its double.
const only = planProvenIntegerLoop(
forLoop(body) as never,
new Map([["run", range]]),
all,
() => false,
all,
)!;
expect([...only.locals.keys()]).toEqual(["run"]);
// An unproven sum leaves `run` unshadowed, and its copy reads `run`
// where nothing proves it integral.
const unproven = (e: IrExpr): boolean => e !== sum && e.kind !== "varRef";
expect(
planProvenIntegerLoop(forLoop(body) as never, cursors, all, () => false, unproven),
).toBeNull();
// An expression-position write refuses the binding.
const inner: IrStmt = {
kind: "exprStmt",
expr: { kind: "incDec", op: "+", prefix: false, localId: "longest", type: F64, loc },
loc,
};
expect([
...planProvenIntegerLoop(
forLoop([...body, inner]) as never,
cursors,
all,
() => false,
all,
)!.locals.keys(),
]).toEqual(["run"]);
// A labeled jump may leave past the write-back.
const jump: IrStmt = { kind: "break", label: "outer", loc };
expect(
planProvenIntegerLoop(forLoop([...body, jump]) as never, cursors, all, () => false, all),
).toBeNull();
});
@@ -3,7 +3,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { f64Lit } from "./common.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { withinInt32 } from "../../ir/integer-ranges.js";
import { type IntegerRange, withinInt32 } from "../../ir/integer-ranges.js";
/** Speculative integer loops.
*
@@ -410,6 +410,97 @@ export function planSpeculativeLoop(
return { locals, writes: speculated, observerFree };
}
/** A proven integer loop. The bindings integer-ranges.ts bounds over a for
* loop (`loopRanges`) hold an integer on entry and at every write, so the
* loop keeps them in i64 shadow slots without a second copy or checks, and
* writes their doubles once, when it is left. */
export interface ProvenLoopPlan {
/** Shadowed bindings and their loop ranges. */
locals: Map<string, IntegerRange>;
/** Statement-level writes computed as integers. */
writes: Set<IrStmt>;
}
/** Plan a proven integer loop. `cursors` are the loop's bounded bindings,
* `eligible` the locals a shadow may stand for, `integral` those the emitter
* already holds as integers and `proven` the expressions the range analysis
* proves integral. A shadowed binding's double is stale until the loop is
* left, so every exit has to pass the write-back: the caller plans only
* loops where an exception leaves the function, and a labeled jump (which
* may leave past the write-back) keeps the loop ordinary. */
export function planProvenIntegerLoop(
loop: SpeculativeLoop,
cursors: ReadonlyMap<string, IntegerRange>,
eligible: (localId: string) => boolean,
integral: (localId: string) => boolean,
proven: (e: IrExpr) => boolean,
): ProvenLoopPlan | null {
if (cursors.size === 0) return null;
const writes = new Map<string, IrStmt[]>();
const refused = new Set<string>();
let rejected = false;
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") refused.add(e.localId);
return everyExprChild(e, expr, nested);
};
const nested = (s: IrStmt): boolean => {
if (s.kind === "assign" || s.kind === "varDecl") refused.add(s.localId);
return everyStmtChild(s, expr, nested);
};
const stmt = (s: IrStmt): boolean => {
if (rejected) return false;
if (
!SIMPLE_STATEMENTS.has(s.kind) ||
((s.kind === "break" || s.kind === "continue") && s.label !== undefined)
) {
rejected = true;
return false;
}
const target = s.kind === "varDecl" ? null : speculativeWriteTarget(s);
if (target) {
if (target.value !== null && cursors.has(target.localId)) {
const list = writes.get(target.localId) ?? [];
list.push(s);
writes.set(target.localId, list);
} else refused.add(target.localId);
return target.value === null || expr(target.value);
}
if (s.kind === "varDecl") refused.add(s.localId);
return everyStmtChild(s, expr, stmt);
};
if (loop.cond) expr(loop.cond);
if (loop.kind === "for" && loop.update) nested(loop.update);
for (const s of loop.body) stmt(s);
if (rejected) return null;
const locals = new Map<string, IntegerRange>();
for (const [id, list] of writes) {
if (refused.has(id) || !eligible(id)) continue;
const exact = list.every((s) => {
const value = speculativeWriteTarget(s)!.value!;
return isSpeculableValue(value, false) && proven(value);
});
if (exact) locals.set(id, cursors.get(id)!);
}
// Every binding a shadowed write reads is shadowed, already an integer,
// or proven integral where it is read.
const grounded = (e: IrExpr): boolean =>
e.kind === "varRef"
? locals.has(e.localId) || integral(e.localId) || proven(e)
: everyExprChild(e, grounded, () => false);
for (let changed = true; changed;) {
changed = false;
for (const id of [...locals.keys()])
if (!writes.get(id)!.every((s) => grounded(speculativeWriteTarget(s)!.value!))) {
locals.delete(id);
changed = true;
}
}
if (locals.size === 0) return null;
const planned = new Set<IrStmt>();
for (const id of locals.keys()) for (const s of writes.get(id)!) planned.add(s);
return { locals, writes: planned };
}
const SAFE = Number.MAX_SAFE_INTEGER;
/** The range every speculative shadow holds. */
@@ -501,11 +592,14 @@ export function emitSpeculativeInteger(
e: IrExpr,
bail: (condition: string) => void,
reads: ReadonlyMap<IrExpr, LlValue> = new Map(),
integral = false,
): string {
const B = host.B;
// A value the range analysis already proves takes the ordinary integer
// emission, which needs no checks.
const proven = e.kind === "bin" ? host.integerRanges.get(e) : undefined;
// emission, which needs no checks. A proven integer loop's write
// (`integral`) is proven throughout and computes its operands as integers
// instead: the ordinary emission of a sum past int32 is a double.
const proven = e.kind === "bin" && !integral ? host.integerRanges.get(e) : undefined;
if (proven && proven.min >= -SAFE && proven.max <= SAFE && speculativeReads(e).length === 0)
return speculativeLeaf(host, host.emitExpr(e), e, bail);
switch (e.kind) {
@@ -533,8 +627,8 @@ export function emitSpeculativeInteger(
}
case "bin": {
if (e.op !== "+" && e.op !== "-" && e.op !== "*" && e.op !== "%") break;
const a = emitSpeculativeInteger(host, e.left, bail, reads);
const b = emitSpeculativeInteger(host, e.right, bail, reads);
const a = emitSpeculativeInteger(host, e.left, bail, reads, integral);
const b = emitSpeculativeInteger(host, e.right, bail, reads, integral);
const result = B.tmp();
if (e.op === "+" || e.op === "-") {
// With one operand's range known, the other is checked against the
@@ -590,7 +684,7 @@ export function emitSpeculativeInteger(
}
case "unary": {
if (e.op !== "-") break;
const a = emitSpeculativeInteger(host, e.operand, bail, reads);
const a = emitSpeculativeInteger(host, e.operand, bail, reads, integral);
const zero = B.tmp(),
result = B.tmp();
// -0 is not an integer result.
@@ -49,6 +49,9 @@ export interface NativeLinkFeatures {
/** Own properties JavaScript adds to children: scr_dyn_handle_props.c.
* Absent (false) for programs without them. */
handleProps?: boolean;
/** for await over checked-dynamic values: scr_dyn_for_await.c. Absent
* (false) for programs without it. */
dynForAwait?: boolean;
/** events.once with options: scr_events_once_signal.c. Absent (not
* false) for programs without it. */
eventsOnceSignal?: boolean;
@@ -64,6 +67,7 @@ export interface NativeLinkFeatures {
bigint: boolean;
searchParams: boolean;
qs: boolean;
utilPromisify: boolean;
dynBuiltinClass: boolean;
dynBuiltinStream: boolean;
parseArgs: boolean;
@@ -74,6 +78,8 @@ export interface NativeLinkFeatures {
dgram: boolean;
/** node:dns/promises: scr_dns_promises.c. */
dnsPromises: boolean;
/** dgram sockets as untyped values: scr_dgram_dyn.c. */
dgramDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -118,6 +118,8 @@ export interface CcOptions {
streamDyn?: boolean;
/** Own properties JavaScript adds to children: compiles scr_dyn_handle_props.c. */
handleProps?: boolean;
/** for await over checked-dynamic values: compiles scr_dyn_for_await.c. */
dynForAwait?: boolean;
/** events.once with options (events.onceSignal): compiles
* scr_events_once_signal.c. */
eventsOnceSignal?: boolean;
@@ -165,6 +167,10 @@ export interface CcOptions {
* line (escape-only programs ride the always-linked component encoder
* and never flip this). */
qs?: boolean;
/** The program promisifies an untyped function (util.promisify over
* `any`): compiles scr_util_promisify.c, which also references the
* dyn-async and dyn-invoke units. */
utilPromisify?: boolean;
/** The program uses EventEmitter or a stream class as an untyped
* constructor value (a constructor function's base): compiles
* scr_dyn_builtin_class.c, which also references the inspection,
@@ -205,6 +211,10 @@ export interface CcOptions {
* compiles scr_dns_promises.c over the dgram unit, so callback-only
* binaries keep their exact link line. */
dnsPromises?: boolean;
/** A dgram socket converts to or from an untyped value: compiles
* scr_dgram_dyn.c over the dgram unit, so typed dgram binaries keep
* their exact link line. */
dgramDyn?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
@@ -185,7 +185,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
// Builtin functions read as values in JavaScript dependencies.
unit((u) => u.opts.builtinValues === true, "scr_builtin_values.c"),
unit((u) => u.opts.dc, "scr_dc.c"),
unit((u) => u.opts.dynTier, "scr_js_value.c"),
unit((u) => u.opts.dynTier, "scr_js_value.c", "scr_js_object.c"),
unit((u) => u.opts.eventTarget || u.nativeFetch, "scr_event_target.c"),
unit((u) => u.opts.wasm, "scr_wasm.c"),
unit(
@@ -246,6 +246,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
(u) => u.opts.parseArgs && u.opts.inspect && u.opts.dynAsync && u.opts.dynInvoke && u.regex,
"scr_util_callable.c",
),
// util.promisify over an untyped function: dyn promises and calls.
unit((u) => u.opts.utilPromisify && u.opts.dynAsync && u.opts.dynInvoke, "scr_util_promisify.c"),
unit(
(u) =>
u.opts.dynBuiltinClass &&
@@ -275,6 +277,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.streamDyn === true, "scr_stream_dyn.c"),
// Own properties JavaScript adds to children.
unit((u) => u.opts.handleProps === true, "scr_dyn_handle_props.c"),
// for await over checked-dynamic values.
unit((u) => u.opts.dynForAwait === true, "scr_dyn_for_await.c"),
// FileHandle streams reference both the stream unit and FileHandle.
unit((u) => u.opts.stream && u.opts.fileHandle, "scr_fs_stream_fh.c"),
// The readiness-poller backends (scr_platform.h): kqueue on macOS/BSD,
@@ -288,6 +292,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.http2, "scr_http2.c"),
unit((u) => u.opts.dgram, "scr_dgram.c"),
unit((u) => u.opts.dgram && u.opts.dnsPromises, "scr_dns_promises.c"),
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
@@ -338,6 +343,9 @@ const RUNTIME_DEFINES: readonly {
when: (opts: CcOptions) => boolean | undefined;
}[] = [
{ define: "SCR_TEXT_DECODER_LEGACY", when: (opts) => opts.textDecoderLegacy },
// Checked objects carry the dynamic tier's shapes (scr_json.c's SCR_JS_TIER build); only
// programs with dynamic-tier code select it.
{ define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true },
{ define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace },
{ define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended },
{ define: "SCR_YIN", when: (opts) => opts.youngNursery },
@@ -112,7 +112,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
...(opts.assert && opts.bigint ? ["scr_bigint_assert.c"] : []),
...(opts.emitter ? ["scr_events_emitter.c", "scr_dyn_handle.c"] : []),
...(opts.dynInvoke ? ["scr_dyn_invoke.c"] : []),
...(opts.dynTier === true ? ["scr_js_value.c"] : []),
...(opts.dynTier === true ? ["scr_js_value.c", "scr_js_object.c"] : []),
...(opts.zlib ? ["scr_zlib.c"] : []),
...(opts.copying ? ["scr_copying.c"] : []),
];
@@ -129,6 +129,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
"-DSCR_LIB",
...(opts.threadInstances ? ["-DSCR_THREAD_INSTANCES"] : []),
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
...(opts.dynTier === true ? ["-DSCR_JS_TIER"] : []),
"-I",
rtDir,
...(regex ? ["-I", vendorEngineDir()] : []),
@@ -86,6 +86,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
dynTier: boolean;
streamDyn: boolean;
handleProps: boolean;
dynForAwait: boolean;
eventsOnceSignal: boolean;
dynBufferNum: boolean;
builtinValues: boolean;
@@ -282,6 +283,7 @@ export function effectiveRuntimeFeatures(
dynTier: features.dynTier === true,
streamDyn: features.streamDyn === true,
handleProps: features.handleProps === true,
dynForAwait: features.dynForAwait === true,
eventsOnceSignal: features.eventsOnceSignal === true,
dynBufferNum: features.dynBufferNum === true,
builtinValues: features.builtinValues === true,
@@ -107,6 +107,7 @@ const features = {
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -116,6 +117,7 @@ const features = {
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -44,6 +44,7 @@ const BASE: NativeLinkFeatures = {
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -53,6 +54,7 @@ const BASE: NativeLinkFeatures = {
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -50,6 +50,7 @@ export async function warmNativeCaches(
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -59,6 +60,7 @@ export async function warmNativeCaches(
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
+1 -4
View File
@@ -889,10 +889,7 @@ class Int32Contracts {
const value = s.kind === "varDecl" ? s.init : s.value;
if (note && value && !int32(value))
fail(note, value, value.loc, "the value stored into");
} else if (
s.kind === "forOf" ||
(s.kind === "tryCatch" && s.catchLocalId !== undefined)
) {
} else if (s.kind === "forOf" || (s.kind === "tryCatch" && s.catchLocalId !== null)) {
const id = s.kind === "forOf" ? s.localId : s.catchLocalId!;
const note = localNote(id);
if (note)
@@ -45,6 +45,7 @@ const native: EarlyExecutableNativeFeatures = {
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -54,6 +55,7 @@ const native: EarlyExecutableNativeFeatures = {
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -68,6 +68,7 @@ export interface EarlyExecutableNativeFeatures {
dynTier?: boolean;
streamDyn?: boolean;
handleProps?: boolean;
dynForAwait?: boolean;
eventsOnceSignal?: boolean;
dynBufferNum?: boolean;
builtinValues?: boolean;
@@ -77,6 +78,7 @@ export interface EarlyExecutableNativeFeatures {
bigint: boolean;
searchParams: boolean;
qs: boolean;
utilPromisify: boolean;
dynBuiltinClass: boolean;
dynBuiltinStream: boolean;
parseArgs: boolean;
@@ -86,6 +88,7 @@ export interface EarlyExecutableNativeFeatures {
http2: boolean;
dgram: boolean;
dnsPromises: boolean;
dgramDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -199,6 +202,7 @@ const BOOLEAN_NATIVE_KEYS = [
"bigint",
"searchParams",
"qs",
"utilPromisify",
"dynBuiltinClass",
"dynBuiltinStream",
"parseArgs",
@@ -208,6 +212,7 @@ const BOOLEAN_NATIVE_KEYS = [
"http2",
"dgram",
"dnsPromises",
"dgramDyn",
"watch",
"foreignFfi",
"nodeTest",
@@ -0,0 +1,34 @@
import { afterAll, expect, test } from "vitest";
import { argumentsAliasingUnobservable } from "./arguments-usage.js";
import * as ts from "./ts7/adapter.js";
import { closeSourceParser, parseSourceFile } from "./ts7/source-parser-node.js";
afterAll(closeSourceParser);
function unobservable(source: string): boolean {
const file = parseSourceFile("scan.js", source, "js");
const fn = file.statements.nodes.find(ts.isFunctionDeclaration);
if (fn === undefined) throw new Error("no function declaration");
return argumentsAliasingUnobservable(fn);
}
test("element reads stay unobservable while nothing writes a parameter", () => {
expect(unobservable("function f(a, b) { return arguments[0] + arguments.length; }")).toBe(true);
expect(unobservable("function f(a) { var a; return arguments[0]; }")).toBe(true);
expect(unobservable("function f(a) { var b = 1; return arguments[0] + b; }")).toBe(true);
});
test("a parameter written by assignment or by a declaration makes element reads observable", () => {
for (const body of [
"a = 5;",
"a++;",
"var a = 5;",
"for (var a in o);",
"for (var a of o);",
"var { a } = o;",
"var [a] = o;",
"function a() {}",
]) {
expect(unobservable(`function f(a, o) { ${body} return arguments[0]; }`), body).toBe(false);
}
});
@@ -33,3 +33,106 @@ export function bodyReadsArguments(fn: ts.Node): boolean {
});
return found;
}
/** Whether an expression is assigned, updated or deleted. */
function isWritten(expr: ts.Node): boolean {
let node = expr;
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (ts.isBinaryExpression(parent))
return (
parent.left === node &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
);
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent))
return (
parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken
);
if (ts.isForInStatement(parent) || ts.isForOfStatement(parent))
return parent.initializer === node;
if (
ts.isArrayLiteralExpression(parent) ||
ts.isSpreadElement(parent) ||
ts.isShorthandPropertyAssignment(parent) ||
(ts.isPropertyAssignment(parent) && parent.initializer === node)
)
return isWritten(parent);
if (ts.isObjectLiteralExpression(parent) || ts.isSpreadAssignment(parent))
return isWritten(parent);
return ts.isDeleteExpression(parent);
}
/** Whether an identifier assigns its binding where it declares it: a
* `var` with an initializer or in a for-in/for-of head, a destructured
* element, or a function declaration's name. A redeclared parameter keeps
* its binding, so each of these writes the parameter. */
function isDeclarationWrite(id: ts.Identifier): boolean {
const parent = id.parent;
if (ts.isVariableDeclaration(parent) && parent.name === id) {
if (parent.initializer !== undefined) return true;
const head = parent.parent?.parent;
return (
(ts.isForInStatement(head) || ts.isForOfStatement(head)) && head.initializer === parent.parent
);
}
if (ts.isBindingElement(parent)) return parent.name === id;
return ts.isFunctionDeclaration(parent) && parent.name === id;
}
/** Whether sloppy-mode `arguments` in `fn`'s own body behaves exactly like
* an unmapped copy of the call's arguments. A mapped arguments object
* aliases the named parameters, so it differs from a copy only when one
* side is written after the other is read: reading `arguments.length`
* never observes the aliasing, and element reads cannot observe it while
* nothing writes an element or a parameter. The object itself must not
* escape, since another holder could write through it. */
export function argumentsAliasingUnobservable(fn: ts.SignatureDeclaration): boolean {
if (fn.body === undefined) return false;
let elementReads = false;
let escapes = false;
ts.walkPreorder(fn.body, (node) => {
if (ts.isTypeNode(node)) return "skip";
if (ts.isFunctionLike(node) && !ts.isArrowFunction(node) && node !== fn) return "skip";
if (!ts.isIdentifier(node) || node.text !== "arguments" || !isArgumentsRead(node))
return undefined;
const parent = node.parent;
if (
ts.isPropertyAccessExpression(parent) &&
parent.expression === node &&
parent.name.text === "length" &&
!isWritten(parent)
)
return undefined;
if (ts.isElementAccessExpression(parent) && parent.expression === node && !isWritten(parent)) {
elementReads = true;
return undefined;
}
escapes = true;
return "stop";
});
if (escapes) return false;
if (!elementReads) return true;
const names = new Set<string>();
for (const p of fn.parameters.nodes) {
if (!ts.isIdentifier(p.name)) return false;
names.add(p.name.text);
}
// Any write to a parameter's name, including from nested closures and
// shadowing bindings, conservatively counts as a parameter write.
let paramWritten = false;
ts.walkPreorder(fn.body, (node) => {
if (ts.isTypeNode(node)) return "skip";
if (
ts.isIdentifier(node) &&
names.has(node.text) &&
(isWritten(node) || isDeclarationWrite(node))
) {
paramWritten = true;
return "stop";
}
return undefined;
});
return !paramWritten;
}
@@ -1,8 +1,18 @@
import { numLit } from "../../../ir/build.js";
import { BOOL, DYN, RUNTIME_EMITTER_CLASS, type IrExpr, type SrcLoc } from "../../../ir/ir.js";
import {
BOOL,
DYN,
RUNTIME_EMITTER_CLASS,
type IrExpr,
type IrStmt,
type SrcLoc,
} from "../../../ir/ir.js";
import * as ts from "../../ts7/adapter.js";
import { locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
import type { ClassInfo } from "../lower-classes.js";
import { classInstanceOf } from "../class-dynamic-dispatch.js";
import { isSafeToDiscard, isSafeToRepeat } from "../expressions/evaluation-safety.js";
/* EventEmitter and the stream classes as untyped constructor values, the
* bases of JavaScript constructor functions:
@@ -157,3 +167,57 @@ export function builtinClassInstanceOf(value: IrExpr, kind: number, loc: SrcLoc)
loc,
};
}
/** `value instanceof Base` for an untyped value that may also hold a
* compiled instance: an object built by a constructor function inherits
* the untyped constructor's prototype, and an instance of a program class
* extending Base boxes as a typed reference the class test recognizes.
* With `callee`, the right-hand side is an untyped value and the class
* test applies only when it is Base's untyped constructor. */
export function builtinClassInstanceOfAny(
lowerer: Lowerer,
value: IrExpr,
kind: number,
info: ClassInfo,
loc: SrcLoc,
callee?: IrExpr,
): IrExpr {
const stmts: IrStmt[] = [];
const once = (expr: IrExpr, name: string, snapshot = false): IrExpr => {
if (!snapshot && isSafeToRepeat(expr)) return expr;
const local = lowerer.declareHiddenLocal(name, DYN);
stmts.push({ kind: "varDecl", localId: local.id, init: expr, loc });
return { kind: "varRef", localId: local.id, type: DYN, loc };
};
// The left operand evaluates first: a variable read keeps its value in
// a local when the right-hand side can assign the variable.
const tested = once(value, "%instanceValue", callee !== undefined && !isSafeToDiscard(callee));
const ctor = callee ? once(callee, "%instanceCallee") : null;
const compiled = classInstanceOf(lowerer, tested, info, loc);
const result: IrExpr = {
kind: "logical",
op: "||",
left: ctor
? { kind: "libCall", fn: "dyn.functionInstanceOf", args: [tested, ctor], type: BOOL, loc }
: builtinClassInstanceOf(tested, kind, loc),
right: ctor
? {
kind: "logical",
op: "&&",
left: {
kind: "dynScalarEq",
left: ctor,
right: builtinClassValue(kind, loc),
type: BOOL,
loc,
},
right: compiled,
type: BOOL,
loc,
}
: compiled,
type: BOOL,
loc,
};
return stmts.length ? { kind: "seqExpr", stmts, result, type: BOOL, loc } : result;
}
@@ -6,7 +6,7 @@ import { registerHttpClientFnBinding } from "../lower-server.js";
import { isPromisifiedZlibMember } from "./compression.js";
import { isPromisifiedCryptoMember } from "./crypto.js";
import { isPromisifiedDnsMember } from "../lower-dgram.js";
import { callbackPromisifyShape } from "./promisify-callback.js";
import { callbackPromisifyShape, untypedPromisifyArgument } from "./promisify-callback.js";
/** Compile-time util.promisify projections. The immutable result binding
* owns no runtime slot: calls rewrite to the target's promise lowering,
@@ -167,7 +167,11 @@ export function registerPromisifiedBuiltinDecl(
: null;
// A typed callback-last function value: the call lowers to a native
// promisified function, and the binding holds it like any closure.
if (!projection && callbackPromisifyShape(lowerer, e, locOf(e)) !== null) return false;
if (
!projection &&
(callbackPromisifyShape(lowerer, e, locOf(e)) !== null || untypedPromisifyArgument(lowerer, e))
)
return false;
if (!projection) {
lowerer.noLowering(
"util.promisify of this target",
@@ -3,6 +3,7 @@ import { type Lowerer } from "../lowerer.js";
import { varRef } from "../../../ir/build.js";
import {
BOOL,
DYN,
type IrExpr,
type IrLocal,
type IrParam,
@@ -90,6 +91,14 @@ function callbackShape(
return { fnT, resultT, args, callbackT, callbackParamT: callbackParamT!, inner, error };
}
/** Whether `promisify(fn)`'s one argument is typed `any`: the function is
* only known at run time, so the promisified function is built there. */
export function untypedPromisifyArgument(lowerer: Lowerer, call: ts.CallExpression): boolean {
if (call.questionDotToken || call.arguments.nodes.length !== 1) return false;
const argNode = call.arguments.nodes[0]!;
return !ts.isSpreadElement(argNode) && (lowerer.typeOf(argNode).flags & ts.TypeFlags.Any) !== 0;
}
/** The callback shape of a `promisify(fn)` call, or null. */
export function callbackPromisifyShape(
lowerer: Lowerer,
@@ -131,7 +140,17 @@ export function lowerCallbackPromisify(
loc: SrcLoc,
): IrExpr | null {
const shape = callbackPromisifyShape(lowerer, call, loc);
if (shape === null) return null;
if (shape === null) {
// An untyped (`any`) function: Node's promisify at run time.
if (!untypedPromisifyArgument(lowerer, call)) return null;
return {
kind: "libCall",
fn: "util.promisifyDyn",
args: [lowerer.lowerExprExpecting(call.arguments.nodes[0]!, DYN)],
type: DYN,
loc,
};
}
const argNode = call.arguments.nodes[0]!;
let fn = lowerer.lowerExpr(argNode);
if (!typeEquals(fn.type, shape.fnT)) fn = lowerer.coerceToExpected(fn, shape.fnT);
@@ -5,9 +5,10 @@ import {
} from "./typed-arguments.js";
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
import { bodyReadsArguments } from "../arguments-usage.js";
import { argumentsAliasingUnobservable, bodyReadsArguments } from "../arguments-usage.js";
import type { Lowerer } from "./lowerer.js";
import {
canBoxFuncIntoDyn,
DYN,
type IrExpr,
type IrFunction,
@@ -143,6 +144,26 @@ export function generatorMeta(
return { yieldT: type.yieldT, nextT: type.nextT, resultType };
}
/** A function expression stored as a prototype method: `C.prototype.name =
* function (...) {}` or `C.prototype[key] = function (...) {}`
* (parentheses allowed around the function). */
export function prototypeAssignedFunction(fn: ts.Node | undefined): boolean {
if (fn === undefined || !ts.isFunctionExpression(fn)) return false;
let node: ts.Node = fn;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (
parent === undefined ||
!ts.isBinaryExpression(parent) ||
parent.right !== node ||
parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
!(ts.isPropertyAccessExpression(parent.left) || ts.isElementAccessExpression(parent.left))
)
return false;
const owner = parent.left.expression;
return ts.isPropertyAccessExpression(owner) && owner.name.text === "prototype";
}
/** A parameter with a type annotation or a JSDoc `@param`/`@type` tag. */
export function documentedParameter(param: ts.ParameterDeclaration): boolean {
return (
@@ -199,6 +220,20 @@ export function paramShape(lowerer: Lowerer, param: ts.ParameterDeclaration): Pa
? { type: DYN, mode: "omittable", bodyType: DYN }
: { type: DYN, mode: param.questionToken ? "omittable" : "required" };
}
if (
isJsSourceFile(ts.getSourceFileOfNode(param)) &&
!param.dotDotDotToken &&
prototypeAssignedFunction(param.parent) &&
!checkedJsDocumentedParameter(lowerer, param)
) {
// A prototype method (`C.prototype.m = function (a) {}`) is called
// through dynamic dispatch, where callers pass whatever they hold. As
// for constructors, JSDoc in a file TypeScript does not check
// documents the usual types without constraining callers.
return param.initializer
? { type: DYN, mode: "omittable", bodyType: DYN }
: { type: DYN, mode: param.questionToken ? "omittable" : "required" };
}
const executor = param.parent;
const construction = executor?.parent;
const nativeExecutor =
@@ -640,6 +675,21 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver) boxes the value into a checked-dynamic function:
// the box completes omitted arguments with undefined and checks every
// argument into the completed ABI, so any boxable signature passes.
if (
target !== undefined &&
(target.flags & ts.TypeFlags.Any) !== 0 &&
canBoxFuncIntoDyn(
funcType,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
)
) {
return;
}
// An object-literal METHOD has no contextual type of its own: the
// literal's contextual type names the member slot it fills. An
// overloaded member's slot holds its erased function, which spells the
@@ -808,7 +858,19 @@ export function collectSignatureInner(lowerer: Lowerer, decl: ts.FunctionDeclara
decl.parameters.nodes.length > 0 ? "arguments" : "dynRest",
);
const nameBlame: ts.Node = decl.name ?? decl;
const returnType = lowerer.declaredReturnType(decl, nameBlame);
let returnType = lowerer.declaredReturnType(decl, nameBlame);
// A JavaScript generator whose inferred return the checker leaves `any`
// (it delegates to itself: `yield* walkTree(sub)`) is still a generator:
// its channels are checked-dynamic values.
if (isGenerator && returnType.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(decl))) {
returnType = {
kind: "generator",
...(isAsync ? { async: true as const } : {}),
yieldT: DYN,
retT: DYN,
nextT: DYN,
};
}
if (isAsync && !isGenerator && returnType.kind !== "promise") {
lowerer.badType(nameBlame, lowerer.typeOf(nameBlame));
}
@@ -839,7 +901,8 @@ export function collectSignatureInner(lowerer: Lowerer, decl: ts.FunctionDeclara
/** Arrows inherit arguments. Ordinary JS functions receive a hidden
* array unless a declared rest parameter already owns that contract.
* Class methods are strict; other parameterized forms require ES modules
* because sloppy-mode arguments alias named parameters. */
* because sloppy-mode arguments alias named parameters, unless the body
* cannot observe that aliasing (argumentsAliasingUnobservable). */
function appendArgumentsParameter(
lowerer: Lowerer,
node: ts.SignatureDeclaration,
@@ -864,7 +927,8 @@ function appendArgumentsParameter(
if (
!classMethod &&
!isNodeEsmFile(ts.getSourceFileOfNode(node), lowerer.program) &&
!unmappedArguments(node)
!unmappedArguments(node) &&
!argumentsAliasingUnobservable(node)
) {
lowerer.unsupported(
"SC1090",
@@ -1058,9 +1122,21 @@ export function lambdaSignature(
// Union-inferred returns adopt too (a mixed-return body inferring a
// SUB-union of the slot's union — adopting is a no-op when the two
// A synchronous object-literal method adopts its slot's return the same
// way: its slot is the property of that name on the literal's
// contextual type. A literal under `satisfies` keeps its own type (the
// constraint is only checked), so its methods keep their inferred
// returns: callers of the value see those, not the constraint's.
const literalMethod =
ts.isMethodDeclaration(node) &&
ts.isObjectLiteralExpression(node.parent) &&
!node.asteriskToken &&
node.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) !== true &&
!isSatisfiesOperand(node.parent);
if (
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
(!isAsyncLike || ret.kind === "promise")
((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
(!isAsyncLike || ret.kind === "promise")) ||
literalMethod
) {
// The slot's signature: the contextual type stripped of its nullish
// parts (an OPTIONAL callback field's context is the whole
@@ -1068,7 +1144,10 @@ export function lambdaSignature(
// declared in USER code: stdlib callback slots (flatMap's
// `U | readonly U[]`, sort comparators) have intrinsic lowerings
// that inspect the INFERRED type, so they must not widen.
const ctxType = lowerer.checker.getContextualType(node);
const ctxType =
literalMethod && ts.isMethodDeclaration(node) && node.parent
? objectLiteralMethodSlot(lowerer, node, node.parent)
: lowerer.checker.getContextualType(node);
const ctxSigs = ctxType
? lowerer.checker.getCallSignatures(lowerer.checker.getNonNullableType(ctxType))
: [];
@@ -1136,6 +1215,7 @@ export function lambdaSignature(
const inferredLiteral =
literalSlot !== undefined && (literalType === null || literalType.kind === "dyn");
if (
!literalMethod &&
!isAsyncLike &&
!node.type &&
!inferredLiteral &&
@@ -1151,3 +1231,35 @@ export function lambdaSignature(
const funcType = funcTypeFromParamShapes(shapes, ret);
return { shapes, funcType };
}
/** Whether the object literal `literal` is the operand of a `satisfies`
* expression, directly or as a property value of literals that are. */
function isSatisfiesOperand(literal: ts.Node): boolean {
let n: ts.Node | undefined = literal;
while (n !== undefined) {
const parent: ts.Node | undefined = n.parent;
if (parent === undefined) return false;
if (ts.isParenthesizedExpression(parent)) n = parent;
else if (ts.isPropertyAssignment(parent) && parent.initializer === n) n = parent.parent;
else return ts.isSatisfiesExpression(parent) && parent.expression === n;
}
return false;
}
/** The type of the property an object-literal method fills on the
* literal's contextual type; undefined when the literal has no contextual
* type or the method has a computed name. */
function objectLiteralMethodSlot(
lowerer: Lowerer,
node: ts.MethodDeclaration,
literal: ts.Node,
): ts.Type | undefined {
if (!ts.isIdentifier(node.name) && !ts.isStringLiteral(node.name)) return undefined;
const literalType = lowerer.checker.getContextualType(literal);
if (!literalType) return undefined;
const property = lowerer.checker.getPropertyOfType(
lowerer.checker.getNonNullableType(literalType),
node.name.text,
);
return property ? lowerer.checker.getTypeOfSymbol(property) : undefined;
}
@@ -27,6 +27,7 @@ import { locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
import { own } from "../lowerer.js";
import { isRequireMainFilename } from "../expressions/optional-chains.js";
import { isSafeToDiscard } from "../expressions/evaluation-safety.js";
import { STRING_INDEX_METHODS, STRING_REPLACE_METHODS, STR_METHODS } from "../surfaces.js";
import { execResultCaptureMask } from "./exec-result-uses.js";
import { lowerStringReplacement } from "./string-replacement.js";
@@ -1504,6 +1505,7 @@ function lowerLocaleCompareCall(
/** String.prototype members (Annex B included) whose typed-receiver
* lowering lives here. */
export const EXTRA_STRING_METHODS: ReadonlySet<string> = new Set([
"concat",
"substr",
"toLocaleLowerCase",
"toLocaleUpperCase",
@@ -1565,10 +1567,17 @@ export function isDynPrimitiveKindMethod(name: string): boolean {
return DYN_STRING_KIND_METHODS.has(name) || DYN_NUMBER_KIND_METHODS.has(name);
}
/** Evaluate `value` once into a hidden local (effect-free reads stay). */
function hoist(lowerer: Lowerer, value: IrExpr, stmts: IrStmt[], name: string): IrExpr {
/** Evaluate `value` once into a hidden local (effect-free reads stay,
* unless `snapshot` asks for the value a variable holds now). */
function hoist(
lowerer: Lowerer,
value: IrExpr,
stmts: IrStmt[],
name: string,
snapshot = false,
): IrExpr {
if (
value.kind === "varRef" ||
(value.kind === "varRef" && !snapshot) ||
value.kind === "numLit" ||
value.kind === "strLit" ||
value.kind === "boolLit" ||
@@ -1647,6 +1656,13 @@ function buildStringMethod(
): IrExpr | null {
const loc = locOf(call);
const node = (i: number): ts.Expression => call.arguments.nodes[i] ?? call;
// concat(...values): each argument's ToString, in order, after all of
// them evaluated.
if (name === "concat") {
return args
.map((arg, i) => stringArg(lowerer, arg, node(i)))
.reduce((left, right) => ({ kind: "strConcat", left, right, type: STRING, loc }), s);
}
if (name === "substr") {
const start = args[0]
? positionNumber(lowerer, args[0], numLit(0, loc), node(0), "substr start")
@@ -1767,21 +1783,39 @@ function buildNumberMethod(
return { kind: "ternary", cond: digits.has, then: fixed, else_: shortest, type: STRING, loc };
}
/** Evaluate the call's arguments, in order, into hidden locals. */
function evaluatedArgs(
/** Operand kinds whose string and number conversions run no program code. */
const PLAIN_CONVERSION_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"f64",
"bigint",
"string",
"bool",
"symbol",
"nullT",
"undefinedT",
]);
/** Evaluate the receiver (when given) and the call's arguments, in order,
* into hidden locals. A variable read stays a read unless a later operand
* can assign the variable, or an argument's conversion can run program
* code: the methods convert their arguments after evaluating all of them
* (an object's toString or valueOf). */
function evaluatedOperands(
lowerer: Lowerer,
call: ts.CallExpression,
stmts: IrStmt[],
expected: IrType | undefined,
): IrExpr[] {
return call.arguments.nodes.map((arg) =>
hoist(
lowerer,
expected ? lowerer.lowerExprExpecting(arg, expected) : lowerer.lowerExpr(arg),
stmts,
"%methodArg",
),
receiver?: { value: IrExpr; name: string },
): { receiver: IrExpr | undefined; args: IrExpr[] } {
const args = call.arguments.nodes.map((arg) =>
expected ? lowerer.lowerExprExpecting(arg, expected) : lowerer.lowerExpr(arg),
);
const conversionsRunCode = args.some((arg) => !PLAIN_CONVERSION_KINDS.has(arg.type.kind));
const mayChange = (from: number): boolean =>
conversionsRunCode || !args.slice(from).every(isSafeToDiscard);
return {
receiver: receiver && hoist(lowerer, receiver.value, stmts, receiver.name, mayChange(0)),
args: args.map((arg, i) => hoist(lowerer, arg, stmts, "%methodArg", mayChange(i + 1))),
};
}
/** A typed STRING receiver calling one of EXTRA_STRING_METHODS: receiver,
@@ -1795,9 +1829,12 @@ export function lowerExtraStringMethodCall(
): IrExpr | null {
if (call.arguments.nodes.some(ts.isSpreadElement)) return null;
const stmts: IrStmt[] = [];
const s = hoist(lowerer, receiver, stmts, "%stringReceiver");
const args = evaluatedArgs(lowerer, call, stmts, undefined);
const result = buildStringMethod(lowerer, call, name, s, args, receiverNode, stmts);
const operands = evaluatedOperands(lowerer, call, stmts, undefined, {
value: receiver,
name: "%stringReceiver",
});
const s = operands.receiver!;
const result = buildStringMethod(lowerer, call, name, s, operands.args, receiverNode, stmts);
return result ? seq(stmts, result) : null;
}
@@ -1812,9 +1849,12 @@ export function lowerExtraNumberMethodCall(
if (name !== "toPrecision" && !(name === "toExponential" && call.arguments.nodes.length === 1))
return null;
const stmts: IrStmt[] = [];
const x = hoist(lowerer, receiver, stmts, "%numberReceiver");
const args = evaluatedArgs(lowerer, call, stmts, undefined);
const result = buildNumberMethod(lowerer, call, name, x, args, stmts);
const operands = evaluatedOperands(lowerer, call, stmts, undefined, {
value: receiver,
name: "%numberReceiver",
});
const x = operands.receiver!;
const result = buildNumberMethod(lowerer, call, name, x, operands.args, stmts);
return result ? seq(stmts, result) : null;
}
@@ -1838,7 +1878,7 @@ export function lowerDynPrimitiveMethodCall(
const local = lowerer.declareHiddenLocal("%primitiveReceiver", DYN);
stmts.push({ kind: "varDecl", localId: local.id, init: recv, loc });
const r = varRef(local.id, DYN, loc);
const args = evaluatedArgs(lowerer, call, stmts, DYN);
const { args } = evaluatedOperands(lowerer, call, stmts, DYN);
let result: IrExpr = {
kind: "dynInvoke",
recv: r,
@@ -45,6 +45,7 @@ import { lowerElementCompoundParts } from "./element-access.js";
import { InternalCompilerError } from "../../../errors.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { storedClassValueType } from "../lower-classes.js";
import { builtinClassKind } from "../builtins/builtin-class-dyn.js";
import { hasRuntimeStatics } from "../class-runtime-statics.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-module.js";
@@ -1486,6 +1487,25 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
loc,
};
}
// EventEmitter or a stream class against an untyped value in
// JavaScript: the untyped value can hold the class's untyped
// constructor (coerceToExpected), which compares by identity.
if (
dynSide.type.kind === "dyn" &&
isJsSourceFile(expr.getSourceFile()) &&
((scalarSide.type.kind === "moduleNs" && scalarSide.type.moduleId === "builtin:events") ||
(scalarSide.kind === "classRef" && builtinClassKind(scalarSide.className) !== null))
) {
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" ||
@@ -24,6 +24,7 @@ import {
import { isJsSourceFile, locOf } from "../../program.js";
import { hasRuntimeStatics } from "../class-runtime-statics.js";
import { jsBindingHasOpenWrites } from "../lower-stmts.js";
import { documentedParameter, prototypeAssignedFunction } from "../call-signatures.js";
import { narrowClassUnion, narrowStoredClassValue, structuralNarrowing } from "../class-unions.js";
import { checkedClassAssertion, checkedClassUnionAssertion } from "../class-assertions.js";
import {
@@ -487,6 +488,20 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
)
)
return expr;
// A prototype method in a file TypeScript does not check takes its
// documented parameters as checked-dynamic values whatever their
// JSDoc says (paramShape); reads keep the actual value.
if (
symbol &&
!lowerer.checkedJsFile(ts.getSourceFileOfNode(node)) &&
symbol.declarations.some(
(decl) =>
ts.isParameter(decl) &&
prototypeAssignedFunction(decl.parent) &&
documentedParameter(decl),
)
)
return expr;
if (
symbol &&
symbol.declarations.some(
@@ -255,6 +255,25 @@ function isStatFsDynProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression
);
}
/** The dgram.Socket methods its checked-dynamic handle answers as
* function values (scr_dgram.c's scr_dgram_dyn_method_names). */
const DGRAM_METHOD_VALUES = new Set([
"on",
"once",
"addListener",
"removeListener",
"off",
"emit",
"send",
"close",
"bind",
"connect",
"disconnect",
"address",
"ref",
"unref",
]);
export function lowerPropertyAccessExpr(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
@@ -264,6 +283,26 @@ export function lowerPropertyAccessExpr(
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
}
// A dgram socket's method read as a value in JavaScript (`emit:
// socket.emit.bind(socket)`): the socket crosses as its checked-dynamic
// handle, whose methods are function values that dispatch on their
// receiver, as the prototype methods do. The receiver is a native
// socket whatever its declaration spells (the shipped types declare no
// EventEmitter members), so the name decides.
if (
!expr.questionDotToken &&
DGRAM_METHOD_VALUES.has(expr.name.text) &&
isJsSourceFile(ts.getSourceFileOfNode(expr)) &&
lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "dgramSocket"
) {
return {
kind: "dynKeyGet",
value: lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN),
key: { kind: "strLit", value: expr.name.text, type: STRING, loc },
type: DYN,
loc,
};
}
// `fn.name` on a function parameter of a generic instance keyed by
// its argument's static name (call-site specialization).
{
@@ -1,6 +1,6 @@
/* instanceof and in, including private-name and runtime-key in. */
import * as ts from "../../ts7/adapter.js";
import { builtinClassInstanceOf, builtinClassKind } from "../builtins/builtin-class-dyn.js";
import { builtinClassInstanceOfAny, builtinClassKind } from "../builtins/builtin-class-dyn.js";
import {
checkedPrivateIn,
localPrivateBrandClass,
@@ -401,6 +401,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
}
const left = lowerer.lowerExpr(expr.left);
if (left.type.kind === "dyn") {
// An untyped binding the checker narrowed to EventEmitter or a
// stream class (`ReleaseEmitter = EventEmitter` in JavaScript):
// the binding holds an untyped constructor, so the test is
// Function.prototype[Symbol.hasInstance] over it.
const builtinKind = targetInfo.def.runtime ? builtinClassKind(targetInfo.def.name) : null;
if (builtinKind !== null) {
const right = lowerer.lowerExpr(expr.right);
if (right.type.kind === "dyn")
return builtinClassInstanceOfAny(lowerer, left, builtinKind, targetInfo, loc, right);
}
if (targetInfo.def.runtime) {
lowerer.unsupported(
"SC1090",
@@ -600,11 +610,11 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
}
// `u instanceof EventEmitter` (or a stream class): an untyped object
// built by a constructor function inherits the untyped constructor's
// prototype (builtin-class-dyn.ts). Compiled emitters and streams have
// no untyped form, so the prototype chain answers for every value.
// prototype (builtin-class-dyn.ts), and a compiled emitter or stream
// boxes as a typed reference the class test answers for.
const builtinKind = builtinClassKind(target.def.name);
if (left.type.kind === "dyn" && builtinKind !== null) {
return builtinClassInstanceOf(left, builtinKind, loc);
return builtinClassInstanceOfAny(lowerer, left, builtinKind, target, loc);
}
if (left.type.kind === "dyn") {
lowerer.unsupported(
@@ -2,6 +2,7 @@ import { dynUndefinedExpr, numLit, varRef } from "../../ir/build.js";
import * as ts from "../ts7/adapter.js";
import {
BOOL,
CAUGHT,
DYN,
F64,
STRING,
@@ -247,6 +248,202 @@ export function lowerCheckedDelegation(
return { kind: "seqExpr", stmts, result, generatorDelegate: true, type: result.type, loc };
}
/** `yield* e` in an async generator: the async iterator protocol (a compiled
* async generator, Symbol.asyncIterator's result, or a synchronous iterator
* whose values are awaited). Each step's result is awaited, its values are
* yielded, and the expression completes with the final result's value. An
* abrupt completion at the yield (a consumer's return() or throw()) closes
* the delegate through its return(), as for-await does; next() arguments
* are not forwarded to the delegate. */
export function lowerAsyncDelegation(
lowerer: Lowerer,
expr: ts.YieldExpression,
source: IrExpr,
): IrExpr {
const loc = locOf(expr);
const gen = lowerer.ctx.generator!;
const slot = (name: string, type: IrType, mutable = false) => {
const local = lowerer.declareHiddenLocal(name, type);
local.mutable = mutable;
return { local, ref: varRef(local.id, type, loc) };
};
const iterator = slot("%adelegate", DYN);
const sync = slot("%adelegateSync", BOOL);
const next = slot("%adelegateNext", DYN);
const step = slot("%adelegateStep", DYN, true);
const needsClose = slot("%adelegateClose", BOOL, true);
const returned = slot("%adelegateReturn", DYN);
const error = slot("%adelegateError", CAUGHT);
const get = (value: IrExpr, key: string): IrExpr => ({
kind: "dynKeyGet",
value,
key: { kind: "strLit", value: key, type: STRING, loc },
type: DYN,
loc,
});
const awaitDyn = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "async.awaitDyn",
args: [value],
type: DYN,
loc,
});
// Each step's awaited result, which must be an object (TypeError
// otherwise), for next() and return() alike.
const invoke = (callee: IrExpr): IrExpr => ({
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
awaitDyn({
kind: "dynCall",
callee,
receiver: iterator.ref,
calleeName: "iterator method",
args: [],
type: DYN,
loc,
}),
],
type: DYN,
loc,
});
const stepNext = invoke(next.ref);
const setClose = (value: boolean): IrStmt => ({
kind: "assign",
localId: needsClose.local.id,
value: { kind: "boolLit", value, type: BOOL, loc },
loc,
});
const close = (): IrStmt[] => [
{ kind: "varDecl", localId: returned.local.id, init: get(iterator.ref, "return"), loc },
{
kind: "if",
cond: {
kind: "unary",
op: "!",
operand: { kind: "dynTest", test: "nullish", value: returned.ref, type: BOOL, loc },
type: BOOL,
loc,
},
then: [{ kind: "exprStmt", expr: invoke(returned.ref), loc }],
else_: null,
loc,
},
];
// A synchronous delegate's values are awaited, its final value too.
const stepValue = (): IrExpr => ({
kind: "ternary",
cond: sync.ref,
then: awaitDyn(get(step.ref, "value")),
else_: get(step.ref, "value"),
type: DYN,
loc,
});
const yielded = stepValue();
const stmts: IrStmt[] = [
{
kind: "varDecl",
localId: iterator.local.id,
init: {
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
{
kind: "libCall",
fn: "dyn.asyncIteratorOpen",
args: [source, { kind: "strLit", value: "", type: STRING, loc }],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
{
kind: "varDecl",
localId: sync.local.id,
init: { kind: "libCall", fn: "dyn.asyncIteratorWasSync", args: [], type: BOOL, loc },
loc,
},
{ kind: "varDecl", localId: next.local.id, init: get(iterator.ref, "next"), loc },
{
kind: "varDecl",
localId: needsClose.local.id,
init: { kind: "boolLit", value: false, type: BOOL, loc },
loc,
},
{ kind: "varDecl", localId: step.local.id, init: stepNext, loc },
{
kind: "tryCatch",
tryBody: [
{
kind: "while",
cond: {
kind: "unary",
op: "!",
operand: {
kind: "dynTest",
test: "truthy",
value: get(step.ref, "done"),
type: BOOL,
loc,
},
type: BOOL,
loc,
},
body: [
setClose(true),
{
kind: "exprStmt",
expr: {
kind: "yieldExpr",
value: lowerer.coerceToExpected(yielded, gen.yieldT),
type: gen.nextT.kind === "undefinedT" ? VOID : gen.nextT,
loc,
},
loc,
},
setClose(false),
{ kind: "assign", localId: step.local.id, value: stepNext, loc },
],
loc,
},
],
catchBody: [
{
kind: "if",
cond: needsClose.ref,
then: [
setClose(false),
{
kind: "tryCatch",
tryBody: close(),
catchBody: [],
catchLocalId: null,
finallyBody: null,
loc,
},
],
else_: null,
loc,
},
{ kind: "rethrow", localId: error.local.id, loc },
],
catchLocalId: error.local.id,
finallyBody: [{ kind: "if", cond: needsClose.ref, then: close(), else_: null, loc }],
loc,
},
];
const expected = lowerer.mapTypeOf(lowerer.typeOf(expr));
const result =
expected && expected.kind !== "void"
? lowerer.coerceToExpected(stepValue(), expected)
: stepValue();
return { kind: "seqExpr", stmts, result, generatorDelegate: true, type: result.type, loc };
}
/** `yield* xs` over an array, a string, a Set or a Map: the built-in
* iterator walked in place, with the same live view as for-of (an array
* re-reads its length every step, a string steps by code point, a Map
@@ -29,11 +29,12 @@ function iterableHeadText(expr: ts.Expression): string | null {
/** V8's TypeError message for iterating a value that is not iterable:
* `x is not iterable` for an identifier or property path, `f is not a
* function or its return value is not iterable` for a call of one, and ""
* for other expressions, which the runtime words by the value's kind. */
export function notIterableSpelling(expr: ts.Expression): string {
* for other expressions, which the runtime words by the value's kind.
* for await words it with "async iterable". */
export function notIterableSpelling(expr: ts.Expression, word = "iterable"): string {
const name = iterableHeadText(expr);
if (name !== null) return `${name} is not iterable`;
if (name !== null) return `${name} is not ${word}`;
const e = unwrapped(expr);
const callee = ts.isCallExpression(e) ? iterableHeadText(e.expression) : null;
return callee !== null ? `${callee} is not a function or its return value is not iterable` : "";
return callee !== null ? `${callee} is not a function or its return value is not ${word}` : "";
}
@@ -3803,8 +3803,20 @@ function lowerDynReceiverMethodCall(
)
)
return true;
// A standard-library member spelled as a function-typed property
// (undici-types declares Headers.append/forEach/... and the body mixin
// as `readonly append: (...) => void`; scriptc's own declarations use
// methods) is still the handle's native method: it takes the shared
// receiver dispatch below either way (which also calls a plain object's
// own member), so the lowering does not depend on which declaration
// file supplies the type.
const libMethod =
prop !== undefined &&
prop.declarations.length > 0 &&
prop.declarations.every((decl) => lowerer.isStdlibFile(decl.getSourceFile()));
return (
prop !== undefined &&
!libMethod &&
prop.declarations.some(
(decl) => ts.isPropertyDeclaration(decl) || ts.isPropertySignature(decl),
)
@@ -9405,7 +9417,9 @@ function lowerObjLitGenericMethodCall(
) {
const value = tryLowerExpression(lowerer, access.expression);
// Builtin function values held as checked-dynamic functions
// (String.fromCharCode, JSON.stringify) bind the same way.
// (String.fromCharCode, JSON.stringify) bind the same way, and so
// does a member that lowers to a checked-dynamic value (a native
// handle's method read in JavaScript).
if (
value &&
(value.type.kind === "dyn" ||
@@ -10300,7 +10314,33 @@ export function lowerStaticNumberGlobal(
const radix: IrExpr = args[1]
? lowerer.lowerExprExpecting(args[1], F64)
: { kind: "numLit", value: 0, type: F64, loc };
const optional = optionalCallValue(lowerer, args[0]!);
let optional = optionalCallValue(lowerer, args[0]!);
let lowered: IrExpr | null = null;
// A possibly-missing or untyped text in JavaScript (`parseInt(process.
// env.PORT, 10)`): TypeScript rejects the call, Node parses the text
// of whatever arrives ("undefined").
if (optional === null && isJsSourceFile(ts.getSourceFileOfNode(call))) {
const argT = lowerer.typeOf(args[0]!);
const parts = argT.isUnionType() ? ts.constituentTypes(argT) : [argT];
if (
parts.some(
(part) =>
(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Any)) !== 0,
)
) {
const raw = lowerer.lowerExpr(args[0]!);
if (raw.type.kind === "union") {
optional = raw;
lowered = raw;
} else {
const text: IrExpr =
raw.type.kind === "dyn"
? { kind: "libCall", fn: "dyn.toStringCoerce", args: [raw], type: STRING, loc }
: lowerer.coerceInto(args[0]!, raw, STRING);
return { kind: "libCall", fn: "num.parseInt", args: [text, radix], type: F64, loc };
}
}
}
if (optional?.type.kind === "union") {
const def = lowerer.unions.get(optional.type.unionId);
const stringTag = def?.arms.findIndex((arm) => arm.kind === "string") ?? -1;
@@ -10399,7 +10439,10 @@ export function lowerStaticNumberGlobal(
return { kind: "call", callee: helper, args: [optional, radix], type: F64, loc };
}
}
const s = lowerer.lowerExprExpecting(args[0]!, STRING);
const s =
lowered !== null
? lowerer.coerceInto(args[0]!, lowered, STRING)
: lowerer.lowerExprExpecting(args[0]!, STRING);
return { kind: "libCall", fn: "num.parseInt", args: [s, radix], type: F64, loc };
}
// STATIC parseFloat/isFinite over exactly-typed arguments —
@@ -90,14 +90,17 @@ import {
withUnitArm,
} from "../type-mapper.js";
import {
canonicalBuiltinModule,
cjsClassExprWholeExportOf,
isCjsJsFile,
isJsSourceFile,
isModuleExportsAccess,
locOf,
requireSpecOf,
} from "../program.js";
import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js";
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
@@ -979,6 +982,19 @@ export function registerBuiltinEmitterClass(lowerer: Lowerer): void {
lowerer.classes.set(RUNTIME_EMITTER_CLASS, info);
}
/** `class Bus extends require("events") {}` in a CommonJS module: the
* events module is the EventEmitter class itself, so the base is the
* runtime emitter, exactly as through a `require("events")` binding. */
export function builtinRequireBaseOf(lowerer: Lowerer, node: ts.Expression): ClassInfo | null {
let expr = node;
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
if (!ts.isCallExpression(expr) || !isJsSourceFile(expr.getSourceFile())) return null;
const spec = requireSpecOf(expr);
if (spec === null || canonicalBuiltinModule(spec) !== "events") return null;
if (!isCjsRequireGlobal(lowerer, expr.expression)) return null;
return lowerer.classes.get(RUNTIME_EMITTER_CLASS) ?? null;
}
/** The runtime-provided node:stream classes as eagerly-registered
* ClassInfos (the emitter story): mapType names `%Readable` et al the
* moment a stream type appears, so the infos must exist before any
@@ -2099,6 +2115,11 @@ export function collectClassShapeInner(
// callee is NOT a mixin function keeps the computed-expression
// fence below.
if (t && ts.isCallExpression(t.expression) && !t.typeArguments?.nodes) {
const requiredBase = builtinRequireBaseOf(lowerer, t.expression);
if (requiredBase) {
base = requiredBase;
continue;
}
const mixinBase = lowerer.mixinCallClassInfoOf(t.expression);
if (mixinBase) {
base = mixinBase;
@@ -5765,7 +5786,7 @@ export function lowerClassExpressionInfo(
)?.types?.nodes[0]?.expression;
if (
heritage &&
(ts.isCallExpression(heritage) ||
((ts.isCallExpression(heritage) && !builtinRequireBaseOf(lowerer, heritage)) ||
(ts.isIdentifier(heritage) &&
(lowerer.peekLocal(heritage) ?? lowerer.globalOf(heritage)) != null))
) {
@@ -9,7 +9,6 @@ import { dynUndefinedExpr, boolLit } from "../../ir/build.js";
* generic rejection, never silence. */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { ladderFenceExpr } from "./lowerer.js";
import { isJsSourceFile, locOf } from "../program.js";
import {
BOOL,
@@ -28,6 +27,7 @@ import {
} from "../../ir/ir.js";
import { resultIsDiscarded } from "./call-position.js";
import { lowerCallbackArg as lowerCallbackArgShared } from "./callback-arg.js";
import { documentedParameter } from "./call-signatures.js";
const DGRAM_SURFACE_HINT =
"bind, connect, disconnect, send, address, close, unref/ref, the socket " +
@@ -521,6 +521,17 @@ function lowerDnsModuleCall(
);
}
/** A listener JavaScript leaves untyped: a function literal with a
* parameter that has neither an annotation nor JSDoc (paramShape makes it
* checked-dynamic), or any other untyped value. */
function untypedJsListener(lowerer: Lowerer, node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e))
return e.parameters.nodes.some((param) => !documentedParameter(param));
return (lowerer.typeOf(e).flags & ts.TypeFlags.Any) !== 0;
}
/** Method calls on dgram.Socket receivers — one entry in lower-calls.ts's
* intrinsic chain (after lowerServerMethodCall). Null for other
* receivers. */
@@ -576,9 +587,9 @@ export function lowerDgramMethodCall(
// send(msg, port, address) — one datagram to an explicit destination
// (the static fast path). Every OTHER shape in a JS source rides the
// checked-dynamic ladder (dgram.sendChk): Node's signature shuffle,
// slice bounds, list/type contracts, port/address validation, and
// the connected-state errors — with the compiler-rendered fence as
// the post-validation tail for the callback/list/connected forms.
// slice bounds, list/type contracts, port/address validation, the
// connected-state errors, then the send itself — lists, connected
// sends and completion callbacks included.
const staticShape =
args.length === 3 &&
!args.some(ts.isSpreadElement) &&
@@ -602,13 +613,13 @@ export function lowerDgramMethodCall(
}
if (
isJsSourceFile(ts.getSourceFileOfNode(call)) &&
args.length <= 5 &&
args.length <= 6 &&
!args.some(ts.isSpreadElement)
) {
const receiver = lowerer.lowerExpr(access.expression);
const slots: IrExpr[] = [];
let ok = true;
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 6; i++) {
const n = args[i];
if (!n) {
slots.push(dynUndefinedExpr(loc));
@@ -636,16 +647,7 @@ export function lowerDgramMethodCall(
return {
kind: "libCall",
fn: "dgram.sendChk",
args: [
receiver,
...slots,
ladderFenceExpr(
lowerer,
`send in this form`,
call,
"send(msg, port, address) — one string or Buffer datagram — is the lowered form; callback, list, and connected sends have no lowering yet",
),
],
args: [receiver, ...slots],
type: VOID,
loc,
};
@@ -709,6 +711,22 @@ export function lowerDgramMethodCall(
}
if ((name === "on" || name === "once") && args.length === 2) {
requireStatementPosition(lowerer, call, `socket.${name}(...)`);
// A JavaScript listener whose parameters are untyped registers through
// the socket's checked-dynamic handle, which hands it untyped values.
if (isJsSourceFile(ts.getSourceFileOfNode(call)) && untypedJsListener(lowerer, args[1]!)) {
return {
kind: "dynInvoke",
recv: lowerer.coerceToExpected(lowerer.lowerExpr(access.expression), DYN),
method: name,
calleeName: ts.getTextOfNode(access),
args: [
lowerer.lowerExprExpecting(args[0]!, DYN),
lowerer.lowerExprExpecting(args[1]!, DYN),
],
type: DYN,
loc,
};
}
const once = boolLit(name === "once", loc);
const evT = lowerer.typeOf(args[0]!);
const event = evT.isStringLiteralType() ? evT.value : null;
@@ -26,7 +26,11 @@ import {
import { locOf } from "../program.js";
import { genResultRecord } from "../type-mapper.js";
import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js";
import { lowerBuiltinDelegation, lowerCheckedDelegation } from "./generator-delegation.js";
import {
lowerAsyncDelegation,
lowerBuiltinDelegation,
lowerCheckedDelegation,
} from "./generator-delegation.js";
export type GenType = IrType & { kind: "generator" };
@@ -818,14 +822,21 @@ export function lowerYieldStarStatement(lowerer: Lowerer, expr: ts.Expression):
function lowerYieldStar(lowerer: Lowerer, expr: ts.YieldExpression): IrExpr {
const gen = lowerer.ctx.generator;
if (!gen) lowerer.unsupported("SC1071", expr);
if (!expr.expression) lowerer.unsupported("SC1071", expr, "'yield*' with no operand");
// An async generator delegates through the async iterator protocol: a
// compiled async generator operand crosses as its checked capsule.
if (lowerer.ctx.isAsync) {
const source = lowerer.lowerExpr(expr.expression);
const delegate =
source.kind === "dynCheck" && source.value.type.kind === "dyn" ? source.value : source;
if (delegate.type.kind === "dyn" || lowerer.dynConvertible(delegate.type))
return lowerAsyncDelegation(lowerer, expr, lowerer.coerceToExpected(delegate, DYN));
lowerer.unsupported(
"SC1071",
expr,
"'yield*' in async generators (use 'for await' and yield each value explicitly)",
"'yield*' in async generators over this operand (use 'for await' and yield each value explicitly)",
);
}
if (!expr.expression) lowerer.unsupported("SC1071", expr, "'yield*' with no operand");
const loc = locOf(expr);
// An array literal operand builds as its own array type: the yield*
// context would otherwise type it by the generator.
@@ -134,6 +134,7 @@ import { recordTextCodecClass } from "../../ir/ir.js";
import { isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js";
import type { ClassInfo } from "./lower-classes.js";
import {
builtinRequireBaseOf,
collectInheritedJsFieldWrites,
collectVirtualJsMethods,
decoratorNodesOf,
@@ -432,7 +433,8 @@ export function collectProgram(lowerer: Lowerer, parts: FileParts[]): void {
const heritage = decl.heritageClauses?.nodes.find(
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
)?.types?.nodes[0]?.expression;
if (heritage && ts.isCallExpression(heritage)) computed.add(decl);
if (heritage && ts.isCallExpression(heritage) && !builtinRequireBaseOf(lowerer, heritage))
computed.add(decl);
}
// Descendants need the computed ancestor's completed layout before
// their own fields and inherited methods can be collected.
@@ -622,6 +622,20 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp
: lowerer.lowerExpr(argument);
stmts.push({ kind: "exprStmt", expr: discarded, loc: discarded.loc });
}
// A union input holding a URL arm (`string | URL`) converts through the
// checked-dynamic input path, which reads the URL's href at the call
// like the bare-URL arm below; narrowing it to a string would throw on
// the URL arm.
const unionWithUrl =
input.type.kind === "union" &&
(lowerer.unions.get(input.type.unionId)?.arms.some((arm) => arm.kind === "url") ?? false) &&
lowerer.dynConvertible(input.type);
const dynInput: IrExpr | null =
input.type.kind === "dyn"
? inputRef
: unionWithUrl
? { kind: "dynFrom", value: inputRef, type: DYN, loc: locOf(inputNode) }
: null;
const url: IrExpr =
input.type.kind === "url"
? {
@@ -631,13 +645,13 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp
type: STRING,
loc: locOf(inputNode),
}
: input.type.kind === "dyn"
? inputRef
: dynInput !== null
? dynInput
: lowerer.coerceInto(inputNode, inputRef, STRING);
const answer: IrExpr = {
kind: "libCall",
fn: input.type.kind === "dyn" ? "fetch.input" : "fetch.start",
args: [input.type.kind === "dyn" ? inputRef : url, initRef],
fn: dynInput !== null ? "fetch.input" : "fetch.start",
args: [url, initRef],
type: { kind: "promise", inner: DYN },
loc,
};
@@ -9222,6 +9222,14 @@ function lowerArrayAssignInto(
* specific diagnostics; other iterables fail on their types. The loop
* variable is a fresh const binding per iteration (its own scope, like a
* loop body). */
/** Whether a `for await` head is untyped: the checker answers any or
* unknown, or a type that maps to a checked-dynamic value. */
function isUntypedIterable(lowerer: Lowerer, node: ts.Expression): boolean {
const type = lowerer.typeOf(node);
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true;
return lowerer.mapTypeOf(type)?.kind === "dyn";
}
export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
// Labels consume HERE (nested statements must never see them). The
// plain array/string paths carry them; the container/matchAll/stdin
@@ -9283,6 +9291,32 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
if (dynStream) return dynStream;
const builtin = lowerForAwaitBuiltin(lowerer, stmt);
if (builtin) return builtin;
// An untyped value (a JavaScript async generator whose recursive
// return the checker types `any`): a compiled async generator steps
// natively; any other value takes the checked-dynamic async protocol.
// A synchronous generator, and in JavaScript any other head that
// reaches here, takes the same protocol: values of a synchronous
// iterator are awaited as the async-from-sync iterator awaits them, and
// a value that is not iterable throws Node's TypeError.
const headT = lowerer.mapTypeOf(lowerer.typeOf(stmt.expression));
const syncGenerator =
(headT?.kind === "generator" && headT.async !== true) || isJsSourceFile(stmt.getSourceFile());
if (lowerer.ctx.isAsync && (syncGenerator || isUntypedIterable(lowerer, stmt.expression))) {
const iterable = lowerer.lowerExpr(stmt.expression);
if (iterable.type.kind === "generator" && iterable.type.async)
return lowerForAwaitGenerator(
lowerer,
stmt,
iterable as IrExpr & { type: GenType },
labels,
);
if (iterable.type.kind === "dyn") return lowerForOfDyn(lowerer, stmt, iterable, labels, true);
if (
(iterable.type.kind === "generator" || isJsSourceFile(stmt.getSourceFile())) &&
lowerer.dynConvertible(iterable.type)
)
return lowerForOfDyn(lowerer, stmt, lowerer.coerceToExpected(iterable, DYN), labels, true);
}
// A NodeJS.ReadableStream iterates as the general Readable.
requestGeneralStreamSurface(lowerer, stmt.expression);
lowerer.unsupported(
@@ -9582,9 +9616,11 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt {
// A CHECKED-DYNAMIC iterable (`unknown[]`, the collapsed
// `(string | object)[]`, JSON-parsed values): a checked IteratorRecord
// binds each yielded dyn value.
// binds each yielded dyn value. Under `for await` (a JavaScript array
// literal mixing promises and plain values) the async-from-sync rules
// await each value.
if (iterable.type.kind === "dyn") {
return lowerForOfDyn(lowerer, stmt, iterable, labels);
return lowerForOfDyn(lowerer, stmt, iterable, labels, stmt.awaitModifier !== undefined);
}
if (tupleSource === null) {
lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression));
@@ -10578,9 +10614,10 @@ function lowerForOfDyn(
stmt: ts.ForOfStatement,
iterable: IrExpr,
labels?: string[],
asyncLoop = false,
): IrStmt {
const loc = locOf(stmt);
const spell = notIterableSpelling(stmt.expression);
const spell = notIterableSpelling(stmt.expression, asyncLoop ? "async iterable" : "iterable");
const iterator = lowerer.declareHiddenLocal("%dofIterator", DYN);
const next = lowerer.declareHiddenLocal("%dofNext", DYN);
const fast = lowerer.declareHiddenLocal("%dofNative", BOOL);
@@ -10591,6 +10628,14 @@ function lowerForOfDyn(
const needsClose = lowerer.declareHiddenLocal("%dofNeedsClose", BOOL);
needsClose.mutable = true;
const error = lowerer.declareHiddenLocal("%dofError", CAUGHT);
const syncIterator = asyncLoop ? lowerer.declareHiddenLocal("%dofSync", BOOL) : null;
const awaitDyn = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "async.awaitDyn",
args: [value],
type: DYN,
loc,
});
const get = (source: IrExpr, key: string): IrExpr => ({
kind: "dynKeyGet",
value: source,
@@ -10643,7 +10688,26 @@ function lowerForOfDyn(
type: BOOL,
loc,
},
then: [{ kind: "exprStmt", expr: call(varRef(returned.id, DYN, loc)), loc }],
then: [
{
kind: "exprStmt",
// return()'s (awaited) result must be an object, as for next().
expr: asyncLoop
? check(
awaitDyn({
kind: "dynCall",
callee: varRef(returned.id, DYN, loc),
receiver: varRef(iterator.id, DYN, loc),
calleeName: "iterator method",
args: [],
type: DYN,
loc,
}),
)
: call(varRef(returned.id, DYN, loc)),
loc,
},
],
else_: null,
loc,
},
@@ -10736,15 +10800,24 @@ function lowerForOfDyn(
{
kind: "varDecl",
localId: value.id,
init: iteratorValue(
lowerer,
varRef(iterator.id, DYN, loc),
varRef(next.id, DYN, loc),
varRef(fast.id, BOOL, loc),
done,
loc,
"iterator method",
),
init: syncIterator
? asyncIteratorValue(
lowerer,
varRef(iterator.id, DYN, loc),
varRef(next.id, DYN, loc),
varRef(syncIterator.id, BOOL, loc),
done,
loc,
)
: iteratorValue(
lowerer,
varRef(iterator.id, DYN, loc),
varRef(next.id, DYN, loc),
varRef(fast.id, BOOL, loc),
done,
loc,
"iterator method",
),
loc,
},
{
@@ -10769,13 +10842,29 @@ function lowerForOfDyn(
localId: iterator.id,
init: check({
kind: "libCall",
fn: "dyn.iterator",
fn: asyncLoop ? "dyn.asyncIteratorOpen" : "dyn.iterator",
args: [iterable, { kind: "strLit", value: spell, type: STRING, loc }],
type: DYN,
loc,
}),
loc,
},
...(syncIterator
? [
{
kind: "varDecl",
localId: syncIterator.id,
init: {
kind: "libCall",
fn: "dyn.asyncIteratorWasSync",
args: [],
type: BOOL,
loc,
},
loc,
} satisfies IrStmt,
]
: []),
{
kind: "varDecl",
localId: next.id,
@@ -10785,7 +10874,9 @@ function lowerForOfDyn(
{
kind: "varDecl",
localId: fast.id,
init: iteratorCanStep(varRef(iterator.id, DYN, loc), varRef(next.id, DYN, loc), loc),
init: asyncLoop
? { kind: "boolLit", value: false, type: BOOL, loc }
: iteratorCanStep(varRef(iterator.id, DYN, loc), varRef(next.id, DYN, loc), loc),
loc,
},
{
@@ -10837,6 +10928,99 @@ function lowerForOfDyn(
}
}
/** One step of `for await` over a checked-dynamic iterator: await next()'s
* result, read done before value, and await each value of a synchronous
* iterator, including its final one, as the async-from-sync iterator does. */
function asyncIteratorValue(
lowerer: Lowerer,
iterator: IrExpr,
next: IrExpr,
sync: IrExpr,
done: IrLocal,
loc: SrcLoc,
): IrExpr {
const result = lowerer.declareHiddenLocal("%asyncIteratorResult", DYN);
const get = (key: string): IrExpr => ({
kind: "dynKeyGet",
value: varRef(result.id, DYN, loc),
key: { kind: "strLit", value: key, type: STRING, loc },
type: DYN,
loc,
});
const awaitDyn = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "async.awaitDyn",
args: [value],
type: DYN,
loc,
});
return {
kind: "seqExpr",
type: DYN,
loc,
stmts: [
{
kind: "varDecl",
localId: result.id,
init: {
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
awaitDyn({
kind: "dynCall",
callee: next,
receiver: iterator,
calleeName: "iterator method",
args: [],
type: DYN,
loc,
}),
],
type: DYN,
loc,
},
loc,
},
{
kind: "assign",
localId: done.id,
value: { kind: "dynTest", test: "truthy", value: get("done"), type: BOOL, loc },
loc,
},
],
// A synchronous iterator's final value is still read and awaited, so a
// throwing getter or a rejected promise there rejects the loop.
result: {
kind: "ternary",
cond: sync,
then: {
kind: "ternary",
cond: varRef(done.id, BOOL, loc),
then: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: awaitDyn(get("value")), loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
else_: awaitDyn(get("value")),
type: DYN,
loc,
},
else_: {
kind: "ternary",
cond: varRef(done.id, BOOL, loc),
then: dynUndefinedExpr(loc),
else_: get("value"),
type: DYN,
loc,
},
type: DYN,
loc,
},
};
}
function lowerForOfString(
lowerer: Lowerer,
stmt: ts.ForOfStatement,
@@ -6,7 +6,12 @@ import {
} from "./general-streams.js";
import { extendAssertedRecordMembers } from "./assertion-members.js";
import { jsDynamicDeclarations } from "./js-dynamic-objects.js";
import { builtinClassValue, builtinMemberClassKind } from "./builtins/builtin-class-dyn.js";
import { isJsSourceFileName } from "../tsc-codes.js";
import {
builtinClassKind,
builtinClassValue,
builtinMemberClassKind,
} from "./builtins/builtin-class-dyn.js";
import {
lifetimeRegionType,
lowerLifetimeRegionCall,
@@ -9010,7 +9015,33 @@ export class Lowerer {
}
// A runtime-provided class value has no checked-dynamic form that
// inspects as Node's does (`[Function: EventEmitter] { ... }`).
// JavaScript sources already read EventEmitter and the stream
// classes as the runtime's untyped constructors (builtin-class-dyn.ts),
// so one of those classes turning untyped there becomes the same
// constructor. `require("events")` is the EventEmitter class itself,
// so its module binding is too (`ReleaseEmitter = EventEmitter` over
// `const EventEmitter = require("events")`).
if (
expr.type.kind === "moduleNs" &&
expr.type.moduleId === "builtin:events" &&
isJsSourceFileName(expr.loc.file)
) {
const value = builtinClassValue(0, expr.loc);
if (expr.kind === "moduleNsRef") return value;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr, loc: expr.loc }],
result: value,
type: DYN,
loc: expr.loc,
};
}
if (expr.type.kind === "classval" && this.classes.get(expr.type.className)?.def.runtime) {
// Only the class itself: a slot of the class's type may hold a
// program subclass, which this constructor would misidentify.
const kind = builtinClassKind(expr.type.className);
if (kind !== null && expr.kind === "classRef" && isJsSourceFileName(expr.loc.file))
return builtinClassValue(kind, expr.loc);
this.pushDiag(
unsupportedDiag(
"SC1090",
@@ -9090,6 +9121,24 @@ export class Lowerer {
);
const handleOk = DYN_HANDLE_KINDS.has(expected.kind);
noteUntypedStreamFlow(this, expected);
// `[EventEmitter]` in JavaScript builds an untyped array of the
// untyped constructor, which a typed array of the class cannot
// hold: no class check admits that constructor.
if (
expr.kind === "dynArrLit" &&
expected.kind === "array" &&
expected.elem.kind === "classval" &&
expr.elems.some((elem) => elem.kind === "libCall" && elem.fn === "dyn.builtinClass")
) {
this.pushDiag(
unsupportedDiag(
"SC1090",
expr.loc,
`holding runtime-provided class values ('${expected.elem.className.slice(1)}') in a typed array`,
),
);
throw new PoisonError();
}
if (
canDynCheckTo(
expected,
@@ -243,7 +243,8 @@ export class StringIndexBounds {
/** Every write of a block-scoped local or parameter, or null for any
* other binding (module imports, var, class members, ...). */
private binding(symbol: ts.Symbol): Binding | null {
if (this.bindings.has(symbol)) return this.bindings.get(symbol)!;
const cached = this.bindings.get(symbol);
if (cached !== undefined) return cached;
const result = this.scanBinding(symbol);
this.bindings.set(symbol, result);
return result;
+2 -1
View File
@@ -2121,7 +2121,8 @@ function stableCreateRequireBindingReason7(
decl: ts.VariableDeclaration,
): string | null {
const cache = program.analysis.createRequireReasons;
if (cache.has(decl)) return cache.get(decl)!;
const cached = cache.get(decl);
if (cached !== undefined) return cached;
const check = (): string | null => {
const stmt = decl.parent?.parent;
if (
@@ -21,9 +21,13 @@ import { dirname, join, resolve } from "node:path";
import { Ts7Api } from "./api.js";
import type { Ts7TimingInfo } from "./session-timing.js";
import type { Ts7FileSystem } from "./rpc-filesystem.js";
import type { CompilerOptions, Diagnostic, SourceFileMetadata } from "./semantic-types.js";
import type { Diagnostic, SourceFileMetadata } from "./semantic-types.js";
import type { SourceFile } from "./ast-types.js";
import type { Ts7SessionProject as Project, Ts7SessionSnapshot as Snapshot } from "./session.js";
import type {
Ts7ProjectCompilerOptions,
Ts7SessionProject as Project,
Ts7SessionSnapshot as Snapshot,
} from "./session.js";
import { CheckerFacade } from "./checker.js";
import {
moduleDetectionKindName,
@@ -45,8 +49,9 @@ import { packageShimDeclarationText } from "../package-shims.js";
/** The compiler options our createProgram accepts: TS7's CompilerOptions
* shape (numeric enums for target/module/moduleResolution — the enums module
* generates them from the pinned SDK) with the 5.9.3 "lib.es2025.d.ts" lib
* spelling also accepted. */
export type Ts7CompilerOptions = CompilerOptions;
* spelling also accepted, plus the project options scriptc adopts that the
* TS7 schema no longer declares (Ts7ProjectCompilerOptions). */
export type Ts7CompilerOptions = Ts7ProjectCompilerOptions;
/* tsconfig JSON wants enum NAMES; the options object carries TS7's numeric
* enum values. Generated name lookups preserve the SDK's reverse mappings
@@ -288,7 +293,7 @@ export class Ts7Program {
private readonly configPath: string,
) {}
getCompilerOptions(): CompilerOptions {
getCompilerOptions(): Ts7CompilerOptions {
return this.project.program.getCompilerOptions();
}
+24 -6
View File
@@ -18,6 +18,24 @@ import type {
Ts7SourceMetadata,
} from "./session-schema.generated.js";
/** Project options tsgo still reports for a tsconfig but the TS 7 SDK
* schema no longer declares. scriptc adopts them (ADOPTED_OPTIONS), so the
* options a project reports are parsed with them declared: a native
* compiler keeps only declared fields when it parses the response. */
export interface Ts7ProjectCompilerOptions extends Ts7CompilerOptionsData {
alwaysStrict?: boolean;
esModuleInterop?: boolean;
allowSyntheticDefaultImports?: boolean;
}
interface Ts7ReportedProjectData extends Ts7ProjectData {
compilerOptions: Ts7ProjectCompilerOptions;
}
interface Ts7ReportedSnapshotData extends Ts7SnapshotData {
projects: Ts7ReportedProjectData[];
}
export interface Ts7FileChanges {
changed?: SemanticDocument[];
created?: SemanticDocument[];
@@ -104,7 +122,7 @@ export class Ts7Session {
updateSnapshot(params: Ts7Update = {}): Ts7SessionSnapshot {
const paths = this.initialize();
const data = parseSemanticJson<Ts7SnapshotData>(
const data = parseSemanticJson<Ts7ReportedSnapshotData>(
this.text("updateSnapshot", JSON.stringify(params)),
);
const snapshot = new Ts7SessionSnapshot(data, this, paths);
@@ -160,7 +178,7 @@ export class Ts7SessionSnapshot {
private disposed = false;
constructor(
data: Ts7SnapshotData,
data: Ts7ReportedSnapshotData,
private readonly session: Ts7Session,
readonly paths: Ts7Paths,
) {
@@ -189,7 +207,7 @@ export class Ts7SessionSnapshot {
}
getDefaultProjectForFile(file: SemanticDocument): Ts7SessionProject | undefined {
this.ensureActive();
const data = this.session.request<Ts7ProjectData | null>("getDefaultProjectForFile", {
const data = this.session.request<Ts7ReportedProjectData | null>("getDefaultProjectForFile", {
snapshot: this.id,
file,
});
@@ -215,12 +233,12 @@ export class Ts7SessionSnapshot {
export class Ts7SessionProject {
readonly id: string;
readonly configFileName: string;
readonly compilerOptions: Ts7CompilerOptionsData;
readonly compilerOptions: Ts7ProjectCompilerOptions;
readonly rootFiles: string[];
readonly program: Ts7SessionProgram;
readonly checker: SemanticChecker;
constructor(data: Ts7ProjectData, snapshot: Ts7SessionSnapshot, session: Ts7Session) {
constructor(data: Ts7ReportedProjectData, snapshot: Ts7SessionSnapshot, session: Ts7Session) {
this.id = data.id;
this.configFileName = data.configFileName;
this.compilerOptions = data.compilerOptions;
@@ -261,7 +279,7 @@ export class Ts7SessionProgram {
...(file === undefined ? {} : { file }),
};
}
getCompilerOptions(): Ts7CompilerOptionsData {
getCompilerOptions(): Ts7ProjectCompilerOptions {
this.ensureActive();
return this.project.compilerOptions;
}
+10 -1
View File
@@ -4718,7 +4718,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// semantics would DEGRADE under the collapse — class instances,
// Maps/Sets, functions (closure identity, `x === String`
// narrowing), promises, regexes, generators, handles — keep their
// existing union homes and fences below. A pending recursive
// existing union homes and fences below. URL is the
// exception: its handle boxes by reference and keeps every member,
// so it collapses too (dynSubsumableUnionArm). A pending recursive
// placeholder cannot collapse (back-references already hold the
// union id); the degenerate recursive spelling keeps its fence.
if (
@@ -7704,6 +7706,13 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean {
case "undefinedT":
case "nullT":
return true;
// URL boxes BY REFERENCE (its checked-dynamic handle keeps identity,
// reads and writes its members through the native accessors, answers
// `instanceof URL`, and checks back out to the typed value), so it
// rides the collapse beside fetch's Request/Response handles:
// `string | URL | Request`, fetch's own input type under @types/node.
case "url":
return true;
case "record":
case "array":
// `T | Promise<T>` with T bound to `unknown`: the checked-dynamic value
+3
View File
@@ -547,6 +547,7 @@ async function compileExecutableNative(
...(features.dynTier ? { dynTier: true } : {}),
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBufferNum ? { dynBufferNum: true } : {}),
...(features.builtinValues ? { builtinValues: true } : {}),
@@ -556,6 +557,7 @@ async function compileExecutableNative(
bigint: features.bigint,
searchParams: features.searchParams,
qs: features.qs,
utilPromisify: features.utilPromisify,
dynBuiltinClass: features.dynBuiltinClass,
dynBuiltinStream: features.dynBuiltinStream,
parseArgs: features.parseArgs,
@@ -565,6 +567,7 @@ async function compileExecutableNative(
http2: features.http2,
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
watch: features.watch,
foreignFfi: features.foreignFfi,
...(features.workers ? { workers: true } : {}),
@@ -542,6 +542,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"util.inherits",
"util.deprecate",
"util.callbackify",
"util.promisifyDyn",
"util.debuglog",
// The URI codecs throw the spec's URIError: decoding on bad hex/invalid
// UTF-8 octets, encoding (and querystring.escape) on a lone surrogate.
@@ -634,6 +635,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"dyn.destructureRest",
"dyn.arrayFromIterator",
"dyn.iterator",
"dyn.asyncIteratorOpen",
"dyn.iteratorResult",
"dyn.iteratorStep",
"dyn.mapSeedEntries",
+12 -4
View File
@@ -219,6 +219,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"dyn.destructureRest": { argTypes: [DYN, STRING], result: DYN },
"dyn.arrayFromIterator": { argTypes: [DYN], result: DYN },
"dyn.iterator": { argTypes: [DYN, STRING], result: DYN },
/** for await over a checked-dynamic value: the loop's iterator (a
* compiled async generator, Symbol.asyncIterator's result, or the sync
* iterator), and whether that iterator is synchronous. */
"dyn.asyncIteratorOpen": { argTypes: [DYN, STRING], result: DYN },
"dyn.asyncIteratorWasSync": { argTypes: [], result: BOOL },
"dyn.iteratorResult": { argTypes: [DYN], result: DYN },
"dyn.iteratorCanStep": { argTypes: [DYN, DYN], result: BOOL },
"dyn.iteratorStep": { argTypes: [DYN], result: DYN },
@@ -1100,6 +1105,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"util.inherits": { argTypes: [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
* whose calls answer a dyn promise the appended callback settles. */
"util.promisifyDyn": { argTypes: [DYN], result: DYN },
"util.debuglog": { argTypes: [DYN, DYN], result: DYN },
"util.typeIs": { argTypes: [DYN, STRING], result: BOOL },
"util.typeValue": { argTypes: [DYN, STRING], result: DYN },
@@ -1516,10 +1524,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"dgram.sendBytes": { argTypes: [DGRAMSOCK_T, BYTES_U8, F64, STRING], result: VOID },
/** The send argument-validation ladder over dyn arguments (Node's
* signature shuffle: slice bounds, list/type contracts, port/address
* validation, connected-state errors) — a fully-validated unconnected
* single-payload send RUNS; callback/list/connected forms meet the
* trailing fence. May-throw. */
"dgram.sendChk": { argTypes: [DGRAMSOCK_T, DYN, DYN, DYN, DYN, DYN, STRING], result: VOID },
* validation, connected-state errors), then the send: lists, connected
* sends and completion callbacks included. Up to six arguments, the
* missing ones undefined. May-throw. */
"dgram.sendChk": { argTypes: [DGRAMSOCK_T, DYN, DYN, DYN, DYN, DYN, DYN], result: VOID },
"dgram.address": { argTypes: [DGRAMSOCK_T], result: VOID },
"dgram.close": { argTypes: [DGRAMSOCK_T], result: VOID },
"dgram.closeCb": {
+76 -11
View File
@@ -36,6 +36,11 @@ function variableIncrement(id: string, value: IrExpr): { localId: string; sign:
* filtering, breaks and continues can only reduce the budget. A nested
* loop or opaque control region invalidates the bindings it writes.
*
* A cursor may also be reset to an integer literal (`run = 0`) or set to
* another bounded binding (`longest = run`): every value it then holds is
* its entry value, a reset value or a value the source held, plus at most
* the increments of the whole loop.
*
* The counter's step is a positive integer literal or a binding the loop
* never writes whose entry fact is a positive integer range (the sieve's
* `j += i`): every update then adds an exact integer in that range, so the
@@ -68,6 +73,8 @@ export function boundedIntegerLoopFacts(
refused = new Set<string>();
const lower = new Map<string, number>(),
upper = new Map<string, number>();
const resets = new Map<string, IntegerRange>(),
copies = new Map<string, Set<string>>();
let opaque = 0;
const write = (id: string, delta: number | null): void => {
written.add(id);
@@ -81,8 +88,29 @@ export function boundedIntegerLoopFacts(
lower.set(id, lo);
upper.set(id, hi);
};
const assignment = (id: string, value: IrExpr): void => {
const delta = increment(id, value);
if (opaque || delta !== null) write(id, delta);
else if (
value.kind === "numLit" &&
Number.isSafeInteger(value.value) &&
!Object.is(value.value, -0)
) {
written.add(id);
const range = resets.get(id);
resets.set(id, {
min: Math.min(range?.min ?? value.value, value.value),
max: Math.max(range?.max ?? value.value, value.value),
});
} else if (value.kind === "varRef" && value.localId !== id) {
written.add(id);
const sources = copies.get(id) ?? new Set<string>();
sources.add(value.localId);
copies.set(id, sources);
} else write(id, null);
};
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr") write(e.localId, increment(e.localId, e.value));
if (e.kind === "assignExpr") assignment(e.localId, e.value);
if (e.kind === "incDec") write(e.localId, e.op === "+" ? 1 : -1);
return everyExprChild(e, expr, stmt);
};
@@ -95,12 +123,19 @@ export function boundedIntegerLoopFacts(
s.kind === "tryCatch" ||
s.kind === "switch";
if (region) opaque++;
if (s.kind === "assign") write(s.localId, increment(s.localId, s.value));
if (s.kind === "assign") assignment(s.localId, s.value);
if (s.kind === "varDecl" || s.kind === "forOf") write(s.localId, null);
const completed = everyStmtChild(s, expr, stmt);
if (region) opaque--;
return completed;
};
// The condition and the update run on every pass too. A binding either
// one writes (`i < (src = next(), n)`) changes in ways the budget does
// not count, so it is neither a stable bound nor a copy source.
opaque++;
expr(loop.cond);
expr(loop.update.value);
opaque--;
loop.body.every(stmt);
if (written.has(counter)) return result;
const stable = (id: string): boolean => {
@@ -147,15 +182,45 @@ export function boundedIntegerLoopFacts(
// Header facts also cover the failing final condition and zero iterations.
const maximum = Math.max(start.max, last + step.max);
if (Number.isSafeInteger(maximum)) result.set(counter, { min: start.min, max: maximum });
for (const [id, hi] of upper) {
const range = initial.get(id);
if (!range || refused.has(id) || id === counter) continue;
const decrement = iterations * lower.get(id)!;
const increment = iterations * hi;
if (!Number.isSafeInteger(decrement) || !Number.isSafeInteger(increment)) continue;
const min = range.min + decrement;
const max = range.max + increment;
if (Number.isSafeInteger(min) && Number.isSafeInteger(max)) result.set(id, { min, max });
// Every value a copy reads lies in its source's range: a cursor bounded
// here, or a binding the loop never writes.
const source = (id: string): IntegerRange | undefined =>
result.get(id) ?? (stable(id) ? initial.get(id) : undefined);
// Copies wait for their sources; a cycle of copies stays unbounded.
for (let changed = true; changed;) {
changed = false;
for (const id of written) {
const range = initial.get(id);
if (!range || refused.has(id) || id === counter || result.has(id)) continue;
let min = range.min,
max = range.max;
const reset = resets.get(id);
if (reset) {
min = Math.min(min, reset.min);
max = Math.max(max, reset.max);
}
let bounded = true;
const sources = copies.get(id);
if (sources)
for (const from of sources) {
const copied = source(from);
if (!copied) bounded = false;
else {
min = Math.min(min, copied.min);
max = Math.max(max, copied.max);
}
}
if (!bounded) continue;
const decrement = iterations * (lower.get(id) ?? 0);
const increment = iterations * (upper.get(id) ?? 0);
if (!Number.isSafeInteger(decrement) || !Number.isSafeInteger(increment)) continue;
min += decrement;
max += increment;
if (Number.isSafeInteger(min) && Number.isSafeInteger(max)) {
result.set(id, { min, max });
changed = true;
}
}
}
return result;
}
@@ -1,5 +1,6 @@
import { expect, test } from "vitest";
import {
analyzeIntegerRangeFacts,
analyzeIntegerRanges,
bitwiseIdentityOperand,
bitwiseRange,
@@ -867,6 +868,99 @@ test("an array length bounds while and for counters even when the body resizes t
expect(analyzeIntegerRanges(g).get(cursor)).toEqual({ min: 0, max: 4294967295 });
});
test("a counter plus or minus a literal compared with a bound narrows the counter", () => {
// for (let i = 0; i + 1 < items.length; i++) { pair(items, i, i + 1); }
const ARR = { kind: "array", elem: F64 } as const;
const items: IrExpr = { loc, kind: "varRef", localId: "items", type: ARR };
const length: IrExpr = {
loc,
kind: "arrIntrinsic",
method: "length",
receiver: items,
args: [],
type: F64,
};
const inside = ref("i"),
next = bin("+", ref("i"), num(1)),
step = bin("+", ref("i"), num(1));
const call: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "call", callee: "pair", args: [items, inside, next], type: F64 },
};
const f = fn([
{
loc,
kind: "for",
init: { loc, kind: "varDecl", localId: "i", init: num(0) },
cond: condition("<", bin("+", ref("i"), num(1)), length),
update: { loc, kind: "assign", localId: "i", value: step },
body: [call],
},
]);
f.locals.push(
{ id: "i", name: "i", type: F64, mutable: true },
{ id: "items", name: "items", type: ARR, mutable: false },
);
const ranges = analyzeIntegerRanges(f);
expect(ranges.get(inside)).toEqual({ min: 0, max: 4294967293 });
expect(ranges.get(next)).toEqual({ min: 1, max: 4294967294 });
expect(ranges.get(step)).toEqual({ min: 1, max: 4294967294 });
// if (1 + x < 10) observe(x); if (x - 2 >= 0) observe(x); with x in [0, 100]
const below = ref(),
above = ref(),
wide = ref();
const g = fn([
assign(bin("&", ref("unknown"), num(100))),
{
loc,
kind: "if",
cond: condition("<", bin("+", num(1), ref()), num(10)),
then: [observe(below)],
else_: null,
},
{
loc,
kind: "if",
cond: condition(">=", bin("-", ref(), num(2)), num(0)),
then: [observe(above)],
else_: null,
},
// A bound beyond 2^52 narrows nothing through an offset.
{
loc,
kind: "if",
cond: condition("<", bin("+", ref(), num(1)), num(2 ** 53)),
then: [observe(wide)],
else_: null,
},
]);
const gRanges = analyzeIntegerRanges(g);
expect(gRanges.get(below)).toEqual({ min: 0, max: 8 });
expect(gRanges.get(above)).toEqual({ min: 2, max: 100 });
expect(gRanges.get(wide)).toEqual({ min: 0, max: 100 });
});
test("an offset comes off a fractional bound after the bound rounds to an integer", () => {
// x = unknown | 0; if (x + 2147483648 > -(2 ** -60)) observe(x): the sum is
// an integer, so the condition holds exactly when x + 2^31 >= 0. Subtracting
// the offset from the fractional bound first would round to -2^31 and drop
// x = -2^31 from the range.
const kept = ref();
const f = fn([
assign(bin("|", ref("unknown"), num(0))),
{
loc,
kind: "if",
cond: condition(">", bin("+", ref(), num(2147483648)), num(-(2 ** -60))),
then: [observe(kept)],
else_: null,
},
]);
expect(analyzeIntegerRanges(f).get(kept)).toEqual({ min: -2147483648, max: 2147483647 });
});
test("whole-program slot facts seed parameters, field reads and call results", () => {
const field: IrExpr = {
loc,
@@ -1041,3 +1135,119 @@ test("bitwise identity operands follow ToInt32 and the shift-count mask", () =>
expect(identity("<<", num(0), x)).toBeNull();
expect(identity(">>>", num(0), x)).toBeNull();
});
test("a for loop bounds cursors it resets and copies, and their writes keep the loop range", () => {
// for (x = 0; x < 100; x++) { if (flag === 0) run = run + 1; else run = 0;
// if (run > longest) longest = run; changes = changes + 1; }
const sum = bin("+", ref("run"), num(1)),
copied = ref("run"),
counted = bin("+", ref("changes"), num(1));
const write = (localId: string, value: IrExpr): IrStmt => ({
loc,
kind: "assign",
localId,
value,
});
const loop: IrStmt & { kind: "for" } = {
loc,
kind: "for",
init: { loc, kind: "varDecl", localId: "x", init: num(0) },
cond: condition("<", ref(), num(100)),
update: assign(bin("+", ref(), num(1))),
body: [
{
loc,
kind: "if",
cond: condition("===", ref("flag"), num(0)),
then: [write("run", sum)],
else_: [write("run", num(0))],
},
{
loc,
kind: "if",
cond: condition(">", ref("run"), ref("longest")),
then: [write("longest", copied)],
else_: null,
},
write("changes", counted),
],
};
const declare = (localId: string): IrStmt => ({ loc, kind: "varDecl", localId, init: num(0) });
const f = fn([declare("run"), declare("longest"), declare("changes"), loop]);
f.locals.push(
...["run", "longest", "changes", "flag"].map((id) => ({
id,
name: id,
type: F64,
mutable: true,
})),
);
const ranges = analyzeIntegerRanges(f);
// Each of the 100 passes adds at most one, so `run + 1` and
// `changes + 1` stay within the loop range though their operands reach it.
const range = { min: 0, max: 100 };
expect(ranges.get(sum)).toEqual({ min: 1, max: 100 });
expect(ranges.get(counted)).toEqual({ min: 1, max: 100 });
expect(ranges.get(copied)).toEqual({ min: 1, max: 100 });
expect(analyzeIntegerRangeFacts(f).loopRanges.get(loop)).toEqual(
new Map([
["run", range],
["longest", range],
["changes", range],
]),
);
for (const mutation of ["fraction", "cycle", "nested"] as const) {
const changed = structuredClone(f);
const body = (changed.body[3] as IrStmt & { kind: "for" }).body;
if (mutation === "fraction") body.push(write("run", num(0.5)));
// `run = longest` and `longest = run` bound neither.
if (mutation === "cycle") body.push(write("run", ref("longest")));
if (mutation === "nested")
body.push({
loc,
kind: "while",
cond: condition("<", ref("unknown"), num(1)),
body: [write("run", num(0))],
});
const cursors = analyzeIntegerRangeFacts(changed).loopRanges.get(changed.body[3]!);
expect(cursors?.has("run"), mutation).toBe(false);
expect(cursors?.get("changes"), mutation).toEqual(range);
}
});
test("a cursor of a loop over a typed array keeps its writes exact up to 2^53 - 1", () => {
// for (x = 0; x < bytes.length; x++) changes = changes + 1;
const bytes: IrExpr = {
loc,
kind: "varRef",
localId: "bytes",
type: { kind: "bytes", elem: "u8" },
};
const length: IrExpr = {
loc,
kind: "bytesIntrinsic",
method: "length",
receiver: bytes,
args: [],
type: F64,
};
const counted = bin("+", ref("changes"), num(1));
const f = fn([
{ loc, kind: "varDecl", localId: "changes", init: num(0) },
{
loc,
kind: "for",
init: { loc, kind: "varDecl", localId: "x", init: num(0) },
cond: condition("<", ref(), length),
update: assign(bin("+", ref(), num(1))),
body: [{ loc, kind: "assign", localId: "changes", value: counted }],
},
]);
f.locals.push(
{ 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 });
});
+129 -25
View File
@@ -15,6 +15,13 @@ export interface IntegerRange {
max: number;
}
export type IntegerRanges = ReadonlyMap<IrExpr, IntegerRange | null>;
/** Facts per expression, and per for loop the bindings its body writes only
* as bounded cursors (boundedIntegerLoopFacts): each holds an integer in its
* loop range on entry and at every write in the loop. */
export interface IntegerRangeFacts {
ranges: IntegerRanges;
loopRanges: ReadonlyMap<IrStmt, ReadonlyMap<string, IntegerRange>>;
}
const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 };
const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 };
/** Int32 and uint32 at once: the bound under which `%` (modeled only for
@@ -215,6 +222,24 @@ export interface IntegerSlotFacts {
elements?: (arr: IrExpr) => boolean;
}
/** A local read, or a numeric local plus or minus an integer literal
* (`i + 1`, `1 + i`, `i - 2`): the local and its offset. */
function offsetRef(e: IrExpr): { localId: string; offset: number } | null {
if (e.kind === "varRef") return { localId: e.localId, offset: 0 };
if (e.kind !== "bin" || (e.op !== "+" && e.op !== "-") || e.type.kind !== "f64") return null;
const literal = (x: IrExpr): number | null =>
x.kind === "numLit" && Number.isSafeInteger(x.value) && !Object.is(x.value, -0)
? x.value
: null;
const right = literal(e.right);
if (e.left.kind === "varRef" && e.left.type.kind === "f64" && right !== null)
return { localId: e.left.localId, offset: e.op === "+" ? right : -right };
const left = literal(e.left);
if (e.op === "+" && e.right.kind === "varRef" && e.right.type.kind === "f64" && left !== null)
return { localId: e.right.localId, offset: left };
return null;
}
/** Structured local dataflow. Calls cannot write uncaptured local slots,
* but argument expressions can; unknown evaluation forms invalidate all
* syntactically written slots before their children are inspected. Loops
@@ -227,8 +252,17 @@ export interface IntegerSlotFacts {
* range those writes and its entry value span across the region.
* Suspensions and captured bindings deliberately remain outside the proof. */
export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {}): IntegerRanges {
return analyzeIntegerRangeFacts(fn, slots).ranges;
}
/** analyzeIntegerRanges with the loop ranges of the function's for loops. */
export function analyzeIntegerRangeFacts(
fn: IrFunction,
slots: IntegerSlotFacts = {},
): IntegerRangeFacts {
const ranges = new Map<IrExpr, IntegerRange | null>();
if (fn.async || fn.generator) return ranges;
const loopRanges = new Map<IrStmt, Map<string, IntegerRange>>();
if (fn.async || fn.generator) return { ranges, loopRanges };
const captures = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
@@ -436,6 +470,26 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
return result;
}
// Loop ranges of the bindings the enclosing for loops bound
// (boundedIntegerLoopFacts) while their bodies are analyzed.
let counterRanges: ReadonlyMap<string, IntegerRange> = new Map();
/** The value a write stores in a binding a for loop bounds. Every value
* the binding holds in the loop lies in its loop range, so the written
* value takes that range even when its operands' ranges add past it
* (`changes = changes + 1` with `changes` at the loop's limit). */
function boundedWrite(id: string, value: IrExpr, facts: Facts): IntegerRange | null {
const bound = counterRanges.get(id);
const before = ranges.get(value);
const computed = expr(value, facts);
if (!bound) return computed;
const range = computed
? { min: Math.max(computed.min, bound.min), max: Math.min(computed.max, bound.max) }
: bound;
if (range.min > range.max) return computed;
ranges.set(value, before === undefined ? range : before ? union(before, range) : null);
return range;
}
function remember(e: IrExpr, range: IntegerRange | null): IntegerRange | null {
// Shared IR objects have to satisfy the proof at every occurrence.
const previous = ranges.get(e);
@@ -508,7 +562,7 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
let ref = condition.left;
let bound = condition.right;
let op: string = condition.op;
if (ref.kind !== "varRef" && bound.kind === "varRef") {
if (offsetRef(ref) === null && offsetRef(bound) !== null) {
[ref, bound] = [bound, ref];
op =
(
@@ -518,18 +572,30 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
>
)[op] ?? "";
}
if (ref.kind !== "varRef") return;
const boundRange =
const target = offsetRef(ref);
if (target === null) return;
const literalBound =
bound.kind === "numLit" && Number.isFinite(bound.value)
? { min: bound.value, max: bound.value }
: ranges.get(bound);
if (!boundRange) return;
if (!literalBound) return;
// An Int32 contract local holds an int32 even where the dataflow has
// no fact for it (loop headers, after unknown regions).
const previous =
facts.get(ref.localId) ??
(eligible.has(ref.localId) ? (slots.locals?.(ref.localId) ?? undefined) : undefined);
facts.get(target.localId) ??
(eligible.has(target.localId) ? (slots.locals?.(target.localId) ?? undefined) : undefined);
if (!previous) return;
// `x + c < b` bounds x as `x < b - c` does. A sum that rounds has a
// magnitude of at least 2^53, so against a bound within 2^52 every
// comparison answers as the exact sum would.
const offset = target.offset;
if (
offset !== 0 &&
(Math.abs(offset) > 2 ** 31 ||
Math.abs(literalBound.min) > 2 ** 52 ||
Math.abs(literalBound.max) > 2 ** 52)
)
return;
if (!truth)
op =
(
@@ -538,21 +604,30 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
string
>
)[op] ?? "";
// The sum `x + c` is an integer, so the bound rounds to an integer first
// and the offset comes off after: integer arithmetic below 2^53 is exact,
// where subtracting the offset from a fractional bound could round
// across an integer.
let { min, max } = previous;
if (op === "<") max = Math.min(max, Math.ceil(boundRange.max) - 1);
else if (op === "<=") max = Math.min(max, Math.floor(boundRange.max));
else if (op === ">") min = Math.max(min, Math.floor(boundRange.min) + 1);
else if (op === ">=") min = Math.max(min, Math.ceil(boundRange.min));
if (op === "<") max = Math.min(max, Math.ceil(literalBound.max) - 1 - offset);
else if (op === "<=") max = Math.min(max, Math.floor(literalBound.max) - offset);
else if (op === ">") min = Math.max(min, Math.floor(literalBound.min) + 1 - offset);
else if (op === ">=") min = Math.max(min, Math.ceil(literalBound.min) - offset);
else if (op === "===") {
min = Math.max(min, boundRange.min);
max = Math.min(max, boundRange.max);
} else if (op === "!==" && boundRange.min === boundRange.max) {
min = Math.max(min, Math.ceil(literalBound.min) - offset);
max = Math.min(max, Math.floor(literalBound.max) - offset);
} else if (
op === "!==" &&
literalBound.min === literalBound.max &&
Number.isInteger(literalBound.min)
) {
// Excluding one value narrows a range that ends at it.
if (min === boundRange.min) min += 1;
else if (max === boundRange.max) max -= 1;
const excluded = literalBound.min - offset;
if (min === excluded) min += 1;
else if (max === excluded) max -= 1;
}
if (min <= max && Number.isInteger(min) && Number.isInteger(max))
facts.set(ref.localId, { min, max });
facts.set(target.localId, { min, max });
}
function expr(e: IrExpr, facts: Facts): IntegerRange | null {
let range: IntegerRange | null = null;
@@ -565,12 +640,17 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
range = facts.get(e.localId) ?? boxedRange(e.localId);
break;
case "assignExpr":
range = expr(e.value, facts);
range = boundedWrite(e.localId, e.value, facts);
set(facts, e.localId, range);
break;
case "incDec": {
const before = facts.get(e.localId) ?? null;
const after = before ? integerArithmeticRange(e.op, before, { min: 1, max: 1 }) : null;
const bound = counterRanges.get(e.localId);
const sum = before ? integerArithmeticRange(e.op, before, { min: 1, max: 1 }) : null;
const after =
bound && sum
? { min: Math.max(sum.min, bound.min), max: Math.min(sum.max, bound.max) }
: sum;
set(facts, e.localId, after);
range = e.prefix ? after : before;
break;
@@ -756,11 +836,11 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
for (const s of stmts) {
switch (s.kind) {
case "varDecl":
case "assign": {
const value = s.kind === "varDecl" ? s.init : s.value;
set(facts, s.localId, value ? expr(value, facts) : null);
set(facts, s.localId, s.init ? expr(s.init, facts) : null);
break;
case "assign":
set(facts, s.localId, boundedWrite(s.localId, s.value, facts));
break;
}
case "exprStmt":
expr(s.expr, facts);
break;
@@ -831,7 +911,31 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
if (s.cond) expr(s.cond, loop);
const inside = new Map(loop);
if (s.cond) refine(s.cond, true, inside);
body(s.body, inside);
// Only the body's writes are the ones the bound counted.
const outer = counterRanges;
const headWrites = s.cond ? writes(s.cond) : new Set<string>();
if (s.update) for (const id of writes(s.update)) headWrites.add(id);
const cursors = new Map<string, IntegerRange>();
for (const [id, range] of bounded)
if (id !== counter?.id && !headWrites.has(id)) cursors.set(id, range);
// A loop analyzed again (inside another loop) keeps the bindings
// every analysis bounds, with the union of their ranges.
const previous = loopRanges.get(s);
if (!previous) loopRanges.set(s, cursors);
else
for (const [id, range] of previous) {
const other = cursors.get(id);
if (other) previous.set(id, union(range, other));
else previous.delete(id);
}
const active = new Map(outer);
for (const [id, range] of cursors) active.set(id, range);
counterRanges = active;
try {
body(s.body, inside);
} finally {
counterRanges = outer;
}
// Continue can bypass any body assignment. The update therefore
// starts from header invariants rather than the body's exit facts,
// plus what the condition proved about bindings the body never
@@ -919,5 +1023,5 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
const entry: Facts = new Map();
if (slots.params) for (const [id, range] of slots.params) set(entry, id, range);
body(fn.body, entry);
return ranges;
return { ranges, loopRanges };
}
+1
View File
@@ -4100,6 +4100,7 @@ export const DYN_HANDLE_KINDS: ReadonlyMap<string, { tag: string; cls: string }>
["http2Session", { tag: "SCR_DYNH_H2_SESSION", cls: "Http2Session" }],
["http2Stream", { tag: "SCR_DYNH_H2_STREAM", cls: "Http2Stream" }],
["httpClientReq", { tag: "SCR_DYNH_HTTP_CLIENT", cls: "ClientRequest" }],
["dgramSocket", { tag: "SCR_DYNH_DGRAM_SOCKET", cls: "Socket" }],
["dir", { tag: "SCR_DYNH_DIR", cls: "Dir" }],
["cryptoHash", { tag: "SCR_DYNH_HASH", cls: "Hash" }],
["cryptoHmac", { tag: "SCR_DYNH_HMAC", cls: "Hmac" }],
+178
View File
@@ -0,0 +1,178 @@
import { expect, test } from "vitest";
import { BOOL, DYN, STRING, VOID, type IrExpr, type IrFunction, type IrStmt } from "./ir.js";
import { JsIcSites, hasJsIcSite } from "./js-ic.js";
const loc = { file: "js-ic.ts", start: 0, end: 0 };
const ref = (id: string, type = DYN): IrExpr => ({ kind: "varRef", localId: id, type, loc });
const str = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc });
const call = (callee: string, ...args: IrExpr[]): IrExpr => ({
kind: "call",
callee,
args,
type: DYN,
loc,
});
const ret = (value: IrExpr | null): IrStmt => ({ kind: "return", value, loc });
function helper(name: string, params: string[], body: IrStmt[], locals: string[] = []): IrFunction {
return {
name,
params: params.map((id) => ({ localId: id, name: id, type: DYN })),
returnType: DYN,
locals: [...params, ...locals].map((id) => ({ id, name: id, type: DYN, mutable: false })),
body,
loc,
};
}
/** `if (dyn.typedRefIs(p, "C")) return undefined` — a capsule branch. */
function capsuleBranch(param: string, fn = "dyn.typedRefIs"): IrStmt {
return {
kind: "if",
cond: {
kind: "libCall",
fn: fn as "dyn.typedRefIs",
args: [ref(param), str("C")],
type: BOOL,
loc,
},
then: [ret(ref(param))],
else_: null,
loc,
};
}
const identityBag = helper(
"%dyn.class.readPropertiesOrEmpty",
["v"],
[capsuleBranch("v"), ret(ref("v"))],
);
function getHelper(name: string, key: string, prefix: IrStmt[] = [], bag?: IrExpr): IrFunction {
const bagRef = ref("bag.0");
return helper(
name,
["p.0"],
[
...prefix,
{ kind: "varDecl", localId: "bag.0", init: bag ?? call(identityBag.name, ref("p.0")), loc },
ret({
kind: "libCall",
fn: "js.fromDyn",
args: [
{
kind: "ternary",
cond: { kind: "dynScalarEq", left: bagRef, right: ref("p.0"), type: BOOL, loc },
then: { kind: "dynKeyGet", key: str(key), value: bagRef, type: DYN, loc },
else_: {
kind: "libCall",
fn: "dyn.bagGet",
args: [bagRef, str(key), ref("p.0")],
type: DYN,
loc,
},
type: DYN,
loc,
},
],
type: { kind: "jsval" },
loc,
}),
],
["bag.0"],
);
}
function setHelper(name: string, key: string, prefix: IrStmt[] = []): IrFunction {
return {
...helper(
name,
["p.0", "p.1"],
[
...prefix,
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySet",
args: [call("%dyn.class.properties", ref("p.0")), str(key), ref("p.1")],
type: VOID,
loc,
},
loc,
},
ret(null),
],
),
returnType: VOID,
};
}
const properties = helper("%dyn.class.properties", ["v"], [capsuleBranch("v"), ret(ref("v"))]);
function sitesOf(...functions: IrFunction[]): JsIcSites {
return new JsIcSites({ functions: [identityBag, properties, ...functions] });
}
test("property helpers whose class branches only test capsules are cache sites", () => {
const sites = sitesOf(
getHelper("%dyn.class.property.0", "x", [
capsuleBranch("p.0"),
capsuleBranch("p.0", "dyn.classIs"),
]),
setHelper("%dyn.class.property.1", "y", [capsuleBranch("p.0", "dyn.typedRefKeyIs")]),
);
expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "get", key: "x" });
expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "set", key: "y" });
});
test("any other prefix or tail keeps the helper call", () => {
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,
};
// A branch an ordinary object can take (toString of an error object).
const withError = getHelper("%dyn.class.property.0", "toString", [errorBranch]);
// A bag that is not the receiver for ordinary objects.
const otherBag = helper("%dyn.class.other", ["v"], [ret({ kind: "dynObjLit", type: DYN, loc })]);
const foreignBag = getHelper("%dyn.class.property.1", "x", [], call(otherBag.name, ref("p.0")));
// A function outside the generated helper family.
const userFn = { ...getHelper("%dyn.class.property.2", "x"), name: "user" };
const sites = sitesOf(withError, otherBag, foreignBag, userFn);
expect(sites.site("%dyn.class.property.0")).toBeNull();
expect(sites.site("%dyn.class.property.1")).toBeNull();
expect(sites.site("user")).toBeNull();
expect(sites.site("%dyn.class.missing")).toBeNull();
});
test("a function holds a site when it calls a classified helper or dispatches a method", () => {
const getter = getHelper("%dyn.class.property.0", "x");
const sites = sitesOf(getter);
const caller = helper("f", ["o"], [ret(call(getter.name, ref("o")))]);
const invoker = helper(
"g",
["o"],
[
ret({
kind: "dynInvoke",
recv: ref("o"),
method: "m",
calleeName: "o.m",
args: [],
type: DYN,
loc,
}),
],
);
const plain = helper("h", ["o"], [ret(ref("o"))]);
expect(hasJsIcSite(caller, sites)).toBe(true);
expect(hasJsIcSite(invoker, sites)).toBe(true);
expect(hasJsIcSite(plain, sites)).toBe(false);
// Module initializers keep plain method dispatch (top-level handle
// wiring), while their property sites still cache.
expect(hasJsIcSite({ ...invoker, name: "%init.0" }, sites)).toBe(false);
expect(hasJsIcSite({ ...caller, name: "%init.0" }, sites)).toBe(true);
});
+236
View File
@@ -0,0 +1,236 @@
/* Inline-cache sites of the dynamic tier (two-tier design stage S4).
*
* Lowering builds every dynamic property access on a constant key as a
* call to a generated helper (class-dynamic-dispatch.ts): a read
* `%dyn.class.property.N(receiver)`, a write
* `%dyn.class.property.N(receiver, value)` and a keyed read
* `%dyn.class.computed.N(receiver, key)`. A helper starts with one branch
* per compiled class whose capsules may reach it and ends with the generic
* checked-dynamic operation on the receiver itself. The capsule branches
* test capsule and class-object kinds only, so for an ordinary checked
* object or array receiver a helper is exactly its generic tail: [[Get]]
* (`dynKeyGet`) or [[Set]] (`dyn.keySet`) of the key.
*
* A call to such a helper from a dynamic-tier function is an inline-cache
* site: the backend (backend/llvm/js-ic.ts) answers plain data properties
* of shaped objects through the site's cache and calls the helper for
* everything else, so the helper stays the single definition of the
* operation's semantics. This module recognizes the helpers by structure,
* never by name alone: a helper whose body is not exactly capsule branches
* plus the generic tail is not a site. `dynInvoke` is a site too: its
* method read on an ordinary object is a [[Get]] (outside module
* initializers, see jsIcMethodSites).
*
* Static functions never contain sites (invariant I1): the backend only
* consults this module for calls in dynamic-tier functions. */
import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
import { everyStmtList } from "./traverse.js";
export type JsIcSite =
| { kind: "get"; key: string }
| { kind: "set"; key: string }
| { kind: "keyed" };
const CAPSULE_TESTS = new Set(["dyn.typedRefIs", "dyn.typedRefKeyIs", "dyn.classIs"]);
/** Classifies the helpers of a module once. */
export class JsIcSites {
private readonly fns: ReadonlyMap<string, IrFunction>;
private readonly sites = new Map<string, JsIcSite | null>();
private readonly identities = new Map<string, boolean>();
constructor(mod: Pick<IrModule, "functions">) {
this.fns = new Map(mod.functions.map((fn) => [fn.name, fn]));
}
/** The cache site a direct call to `callee` is, or null. */
site(callee: string): JsIcSite | null {
let site = this.sites.get(callee);
if (site === undefined) {
const fn = this.fns.get(callee);
site = fn ? this.classify(fn) : null;
this.sites.set(callee, site);
}
return site;
}
private classify(fn: IrFunction): JsIcSite | null {
if (!fn.name.startsWith("%dyn.class.")) return null;
if (fn.async !== undefined || fn.generator !== undefined || fn.captures !== undefined)
return null;
if (fn.params.some((p) => p.type.kind !== "dyn")) return null;
const receiver = fn.params[0]?.localId;
if (receiver === undefined) return null;
let i = 0;
while (i < fn.body.length && this.capsuleBranch(fn.body[i]!, receiver)) i++;
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);
}
/** `varDecl bag = BAG(receiver); return [js.fromDyn](bag === receiver ?
* dynKeyGet(bag, K) : dyn.bagGet(bag, K, receiver))`. */
private getTail(tail: IrStmt[], receiver: string): JsIcSite | null {
const [decl, ret] = tail;
if (tail.length !== 2 || decl?.kind !== "varDecl" || ret?.kind !== "return") return null;
if (decl.init === null || !this.bagOf(decl.init, receiver)) return null;
const bag = decl.localId;
const value = unwrapFromDyn(ret.value);
if (value?.kind !== "ternary") return null;
const { cond, then } = value;
if (
cond.kind !== "dynScalarEq" ||
cond.negated === true ||
!isVar(cond.left, bag) ||
!isVar(cond.right, receiver) ||
then.kind !== "dynKeyGet" ||
!isVar(then.value, bag) ||
then.key.kind !== "strLit"
)
return null;
return { kind: "get", key: then.key.value };
}
/** `dyn.keySet(BAG(receiver), K, value); return`. */
private setTail(tail: IrStmt[], receiver: string, value: string): JsIcSite | null {
const [stmt, ret] = tail;
if (tail.length !== 2 || stmt?.kind !== "exprStmt" || ret?.kind !== "return") return null;
if (ret.value !== null) return null;
const call = stmt.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" || !isVar(stored, value))
return null;
return { kind: "set", key: key.value };
}
/** `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. */
private keyedTail(tail: IrStmt[], receiver: string, key: string): JsIcSite | null {
const [symbol, nullish, decl, ret] = tail;
if (tail.length !== 4) 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;
if (decl?.kind !== "varDecl" || decl.init?.kind !== "libCall") return null;
if (decl.init.fn !== "dyn.toStringCoerce" || !isVar(decl.init.args[0], key)) return null;
if (ret?.kind !== "return") return null;
const read = unwrapFromDyn(ret.value);
if (read?.kind !== "dynKeyGet" || !isVar(read.key, decl.localId)) return null;
if (!this.bagOf(read.value, receiver)) return null;
return { kind: "keyed" };
}
private guard(
s: IrStmt | undefined,
test: "symbol" | "nullish",
tested: string,
object: (e: IrExpr) => boolean,
key: string,
): boolean {
if (s?.kind !== "if" || s.else_ !== null || s.then.length !== 1) return false;
const { cond } = s;
if (cond.kind !== "dynTest" || cond.test !== test || cond.negated === true) return false;
if (!isVar(cond.value, tested)) return false;
const ret = s.then[0]!;
if (ret.kind !== "return") return false;
const read = unwrapFromDyn(ret.value);
return read?.kind === "dynKeyGet" && isVar(read.key, key) && object(read.value);
}
/** A branch that can only be taken for a capsule or class-object
* receiver: `if (<capsule test>) { ... }` with no else. */
private capsuleBranch(s: IrStmt, receiver: string): boolean {
return s.kind === "if" && s.else_ === null && this.capsuleTest(s.cond, receiver);
}
private capsuleTest(e: IrExpr, receiver: string): boolean {
if (e.kind === "libCall")
return CAPSULE_TESTS.has(e.fn) && e.args[0] !== undefined && this.bagOf(e.args[0], receiver);
if (e.kind === "ternary")
return (
this.capsuleTest(e.cond, receiver) && e.else_.kind === "boolLit" && e.else_.value === false
);
return false;
}
/** Whether `e` is the receiver itself for every ordinary object or array:
* the receiver, or a chain of property-bag helpers applied to it. */
private bagOf(e: IrExpr, receiver: string): boolean {
if (isVar(e, receiver)) return true;
return (
e.kind === "call" &&
e.args.length === 1 &&
this.bagOf(e.args[0]!, receiver) &&
this.identityHelper(e.callee)
);
}
/** A property-bag helper that returns its argument unless a capsule
* branch is taken. */
private identityHelper(name: string): boolean {
const known = this.identities.get(name);
if (known !== undefined) return known;
this.identities.set(name, false); // a recursive chain is not an identity
const fn = this.fns.get(name);
let identity = false;
if (
fn &&
name.startsWith("%dyn.class.") &&
fn.params.length === 1 &&
fn.params[0]!.type.kind === "dyn" &&
fn.async === undefined &&
fn.generator === undefined &&
fn.captures === undefined
) {
const param = fn.params[0]!.localId;
const last = fn.body[fn.body.length - 1];
identity =
fn.body.slice(0, -1).every((s) => this.capsuleBranch(s, param)) &&
last?.kind === "return" &&
last.value !== null &&
this.bagOf(last.value, param);
}
this.identities.set(name, identity);
return identity;
}
}
function isVar(e: IrExpr | undefined | null, localId: string): boolean {
return e?.kind === "varRef" && e.localId === localId;
}
function unwrapFromDyn(e: IrExpr | null | undefined): IrExpr | null {
if (!e) return null;
if (e.kind === "libCall" && e.fn === "js.fromDyn" && e.args.length === 1) return e.args[0]!;
return e;
}
/** Whether a function holds a cache site: a call to a classified helper or
* a method dispatch. */
export function hasJsIcSite(fn: IrFunction, sites: JsIcSites): boolean {
const methods = jsIcMethodSites(fn.name);
return !everyStmtList(fn.body, {
expr: (e) =>
!(
(methods && e.kind === "dynInvoke") ||
(e.kind === "call" && sites.site(e.callee) !== null)
),
stmt: () => true,
});
}
/** Whether method dispatch in a function goes through caches. Module
* initializers (`%init.N`) run once, and the methods they call at top level
* are overwhelmingly registrations on native handles (`worker.on(...)`,
* `server.listen(...)`), which a cache never answers: they keep the plain
* dispatch, so a typed program whose only dynamic code is such top-level
* wiring links no dynamic-tier runtime. Property sites still cache there
* (top-level loops over objects). */
export function jsIcMethodSites(fnName: string): boolean {
return !fnName.startsWith("%init.");
}
+44 -2
View File
@@ -42,6 +42,7 @@
* suspensions keep jsvals like any pointer) but keep the dyn result ABI. */
import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js";
import { DYN, JSVAL, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
import { JsIcSites } from "./js-ic.js";
import {
everyExprChild,
everyStmtChild,
@@ -104,8 +105,12 @@ export function jsvalEligible(fn: IrFunction): boolean {
* Returns the number of functions that now use jsval. */
export function promoteJsvals(mod: IrModule): number {
const results = jsvalResults(mod);
const sites = new JsIcSites(mod);
const storesJs = (e: IrExpr): boolean =>
e.kind === "call" && sites.site(e.callee)?.kind === "set";
let count = 0;
for (const fn of mod.functions) if (jsvalEligible(fn) && promoteFunction(fn, results)) count++;
for (const fn of mod.functions)
if (jsvalEligible(fn) && promoteFunction(fn, results, storesJs)) count++;
for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL;
return count;
}
@@ -165,6 +170,7 @@ function promotableLocals(
fn: IrFunction,
producer: (e: IrExpr) => boolean,
jsResult: boolean,
storesJs: (e: IrExpr) => boolean,
): Set<string> {
const candidates = new Map<string, LocalCost>();
for (const local of fn.locals) {
@@ -218,6 +224,14 @@ function promotableLocals(
return;
}
break;
case "call":
// An inline-cache store takes its value as a jsval (S4).
if (storesJs(e)) {
expr(e.args[0]!, false, weight);
expr(e.args[1]!, true, weight);
return;
}
break;
default:
break;
}
@@ -247,6 +261,17 @@ function promotableLocals(
};
const stmt = (s: IrStmt, weight: number): void => {
switch (s.kind) {
case "exprStmt":
// A discarded sequence result (a compound assignment statement)
// is never boxed.
if (discardedSequence(s.expr)) {
for (const inner of s.expr.stmts) stmt(inner, weight);
expr(s.expr.result, true, weight);
return;
}
blockStmtReferences(s);
expr(s.expr, false, weight);
return;
case "return":
if (s.value !== null) expr(s.value, jsResult && s.value.type.kind === "dyn", weight);
return;
@@ -300,15 +325,25 @@ function promotableLocals(
return promoted;
}
/** A statement-level sequence whose result nothing reads. */
function discardedSequence(e: IrExpr): e is Extract<IrExpr, { kind: "seqExpr" }> {
return e.kind === "seqExpr" && e.generatorDelegate === undefined && e.diverges === undefined;
}
/* ── the rewrite ─────────────────────────────────────────────────────── */
function promoteFunction(fn: IrFunction, results: ReadonlySet<string>): boolean {
function promoteFunction(
fn: IrFunction,
results: ReadonlySet<string>,
storesJs: (e: IrExpr) => boolean,
): boolean {
const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee);
const operatorCall = (e: IrExpr): boolean => isProducerCall(e);
const promoted = promotableLocals(
fn,
(e) => operatorCall(e) || resultCall(e),
results.has(fn.name),
storesJs,
);
const ownResult = results.has(fn.name);
let changed = false;
@@ -371,6 +406,13 @@ function promoteFunction(fn: IrFunction, results: ReadonlySet<string>): boolean
if (s.kind === "return" && ownResult && s.value !== null) return { ...s, value: toJs(s.value) };
// A discarded result stays a jsval: nothing boxes it.
if (s.kind === "exprStmt" && producesJs(s.expr)) return { ...s, expr: toJs(s.expr) };
if (s.kind === "exprStmt" && discardedSequence(s.expr) && producesJs(s.expr.result)) {
const result = toJs(s.expr.result);
return {
...s,
expr: { ...s.expr, stmts: s.expr.stmts.map(rewriteStmt), result, type: result.type },
};
}
if (s.kind === "varDecl" && promoted.has(s.localId) && s.init !== null)
return { ...s, init: toJs(s.init) };
if (s.kind === "assign" && promoted.has(s.localId)) return { ...s, value: toJs(s.value) };
+108 -4
View File
@@ -8,6 +8,7 @@ import {
RUNTIME_STREAM_CLASSES,
} from "./ir.js";
import { everyModuleNode } from "./traverse.js";
import { JsIcSites, hasJsIcSite } from "./js-ic.js";
/** The fixed-width Buffer read/write method names (both "UInt" and "Uint"
* spellings) a checked-dynamic method call routes to scr_bytes_dyn.c. */
@@ -92,6 +93,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"dc.chanRunStores",
"dc.chanUnbindStore",
"util.callbackify",
"util.promisifyDyn",
"util.debuglog",
"util.deprecate",
"util.inherits",
@@ -346,6 +348,8 @@ interface TypeTables {
records: Map<string, IrRecordShape>;
unions: Map<string, IrUnionDef>;
classes: Map<string, IrClassDef>;
/** mentionsDgramSocket's carriers, computed on first use. */
dgramCarriers?: DgramCarriers;
}
/** Whether boxing a value of type `t` as dyn boxes a promise, which goes
@@ -409,6 +413,78 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set<string>): boolean
}
}
/** The record shapes, unions and classes whose values carry a dgram socket
* (directly or through other members), computed once per module. */
interface DgramCarriers {
records: Set<string>;
unions: Set<string>;
anyClass: boolean;
}
/** Whether `t` carries a dgram socket, given the carrier sets so far:
* structural through functions (parameters and result), promises and
* collections; named records, unions and class instances by the sets. */
function carriesDgram(t: IrType, carriers: DgramCarriers): boolean {
switch (t.kind) {
case "dgramSocket":
return true;
case "func":
return t.params.some((p) => carriesDgram(p, carriers)) || carriesDgram(t.ret, carriers);
case "promise":
return carriesDgram(t.inner, carriers);
case "array":
case "set":
return carriesDgram(t.elem, carriers);
case "map":
return carriesDgram(t.key, carriers) || carriesDgram(t.value, carriers);
case "record":
return carriers.records.has(t.shapeId);
case "union":
return carriers.unions.has(t.unionId);
case "object":
// Any class may be the instance's runtime class or one of its bases.
return carriers.anyClass;
default:
return false;
}
}
/** The module's dgram carriers: a fixpoint over its records, unions and
* class fields. */
function dgramCarriers(tables: TypeTables): DgramCarriers {
const carriers: DgramCarriers = { records: new Set(), unions: new Set(), anyClass: false };
for (let changed = true; changed;) {
changed = false;
for (const [id, shape] of tables.records)
if (!carriers.records.has(id) && shape.fields.some((f) => carriesDgram(f.type, carriers))) {
carriers.records.add(id);
changed = true;
}
for (const [id, def] of tables.unions)
if (!carriers.unions.has(id) && def.arms.some((a) => carriesDgram(a, carriers))) {
carriers.unions.add(id);
changed = true;
}
if (
!carriers.anyClass &&
[...tables.classes.values()].some((c) => c.fields.some((f) => carriesDgram(f.type, carriers)))
) {
carriers.anyClass = true;
changed = true;
}
}
return carriers;
}
/** Whether converting a value of type `t` to or from dyn converts a dgram
* socket, which goes through scr_dgram_dyn.c: the socket itself, or a
* function, collection, record, union, promise or class instance that
* carries one (boxed functions convert their parameters too). */
function mentionsDgramSocket(t: IrType, tables: TypeTables): boolean {
tables.dgramCarriers ??= dgramCarriers(tables);
return carriesDgram(t, tables.dgramCarriers);
}
/** The libCall fns served by the OPTIONAL events unit (scr_events.c):
* process signal/exit listeners and the piped-stdin surface. */
const PROCESS_EVENT_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
@@ -544,6 +620,14 @@ const RUNTIME_FEATURE_RULES = {
dc: rule({ prefixes: ["dc."] }),
/** dns.lookup lives in the dgram unit. */
dgram: rule({ prefixes: ["dgram.", "dns."], types: ["dgramSocket"] }),
/** A dgram socket converts to or from an untyped value (its handle box,
* a boxed function or record that carries one): scr_dgram_dyn.c over
* the dgram unit. */
dgramDyn: rule({
expr: (node, tables) =>
(node.kind === "dynFrom" && mentionsDgramSocket(node.value.type, tables())) ||
(node.kind === "dynCheck" && mentionsDgramSocket(node.type, tables())),
}),
/** node:dns/promises: scr_dns_promises.c over the dgram unit. */
dnsPromises: rule({ fns: ["dns.promisesLookup", "dns.promisesLookupService"] }),
dynAsync: rule({
@@ -584,7 +668,12 @@ const RUNTIME_FEATURE_RULES = {
dynBuiltinStream: rule({ fns: ["dyn.builtinStreamClass"] }),
dynInvoke: rule({
initial: (mod) => mod.workers === true,
fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS, ...BUILTIN_CLASS_LIB_FNS],
fns: [
...DYN_INVOKE_LIB_FNS,
...UTIL_CALLABLE_LIB_FNS,
...BUILTIN_CLASS_LIB_FNS,
"util.promisifyDyn",
],
// Constant-key reads of Object.prototype member names answer the
// inherited member through scr_dyn_invoke.c.
expr: (node, _tables) =>
@@ -603,6 +692,8 @@ const RUNTIME_FEATURE_RULES = {
}),
/** Own properties JavaScript adds to children (scr_dyn_handle_props.c). */
handleProps: rule({ fns: ["child.propsInstall"] }),
/** for await over checked-dynamic values (scr_dyn_for_await.c). */
dynForAwait: rule({ fns: ["dyn.asyncIteratorOpen", "dyn.asyncIteratorWasSync"] }),
/** Fixed-width Buffer read/write methods called on checked-dynamic
* values (scr_bytes_dyn.c). */
dynBufferNum: rule({
@@ -741,6 +832,10 @@ const RUNTIME_FEATURE_RULES = {
}),
tls: rule({ prefixes: ["tls.", "https.", "http2."], types: ["secureCtx"] }),
tlsCa: rule({ prefixes: ["tlsca."] }),
/** util.promisify over an untyped function (scr_util_promisify.c): its
* promises go through the dyn-async unit and its calls through the
* dyn-invoke unit. */
utilPromisify: rule({ fns: ["util.promisifyDyn"] }),
/** process.emitWarning is assigned (process.setEmitWarning). */
warningReplace: rule({ fns: ["process.setEmitWarning"] }),
/** The WebAssembly API (scr_wasm.c). */
@@ -770,6 +865,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
copying: false,
dc: false,
dgram: false,
dgramDyn: false,
dnsPromises: false,
dynAsync: false,
dynBuiltinClass: false,
@@ -801,11 +897,13 @@ function noRuntimeFeatures(): RuntimeFeatures {
stream: false,
streamDyn: false,
handleProps: false,
dynForAwait: false,
dynBufferNum: false,
builtinValues: false,
symbol: false,
tls: false,
tlsCa: false,
utilPromisify: false,
warningReplace: false,
wasm: false,
webcrypto: false,
@@ -872,6 +970,12 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): Runtim
return keepGoing();
};
if (keepGoing()) everyModuleNode(mod, { expr, stmt: keepGoing, type });
// Inline-cache sites of dynamic-tier functions use the tier's shapes
// (scr_js_object.c, with the SCR_JS_TIER build of scr_json.c).
if (!features.dynTier && mod.functions.some((fn) => fn.tier === "dynamic")) {
const sites = new JsIcSites(mod);
features.dynTier = mod.functions.some((fn) => fn.tier === "dynamic" && hasJsIcSite(fn, sites));
}
return features;
}
@@ -1136,9 +1240,9 @@ export function moduleUsesTlsCa(mod: IrModule): boolean {
return scanRuntimeFeatures(mod, "tlsCa").tlsCa;
}
/** True when the module has dynamic-tier jsval operations: the link switch
* for the scr_js_* runtime units (two-tier design I3). Programs without
* them never link a scr_js_ symbol. */
/** True when the module has dynamic-tier jsval operations or inline-cache
* sites: the link switch for the scr_js_* runtime units (two-tier design
* I3). Programs without them never link a scr_js_ symbol. */
export function moduleUsesDynTier(mod: IrModule): boolean {
return scanRuntimeFeatures(mod, "dynTier").dynTier;
}
+2 -2
View File
@@ -1622,10 +1622,10 @@ function validateFunction(
"while",
]);
if (e.generatorDelegate) {
if (!fn.generator || fn.async)
err("delegation sequence outside a synchronous generator", e.loc);
if (!fn.generator) err("delegation sequence outside a generator", e.loc);
allowed.add("return");
allowed.add("throw");
allowed.add("rethrow");
allowed.add("runtimeFence");
allowed.add("tryCatch");
}
+2
View File
@@ -89,6 +89,7 @@ async function main(): Promise<number> {
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -98,6 +99,7 @@ async function main(): Promise<number> {
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
+2
View File
@@ -143,6 +143,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures {
"bigint",
"searchParams",
"qs",
"utilPromisify",
"dynBuiltinClass",
"dynBuiltinStream",
"parseArgs",
@@ -152,6 +153,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures {
"http2",
"dgram",
"dnsPromises",
"dgramDyn",
"watch",
"foreignFfi",
"nodeTest",
@@ -79,6 +79,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
dynTier: false,
streamDyn: false,
handleProps: false,
dynForAwait: false,
eventsOnceSignal: false,
dynBufferNum: false,
builtinValues: false,
@@ -101,6 +102,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
bigint: false,
searchParams: false,
qs: false,
utilPromisify: false,
dynBuiltinClass: false,
dynBuiltinStream: false,
parseArgs: false,
@@ -110,6 +112,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
http2: false,
dgram: false,
dnsPromises: false,
dgramDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -0,0 +1,92 @@
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, validateModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
import { moduleUsesDynTier } from "../src/ir/runtime-features.js";
/** The LLVM body of a function by source name. */
function body(llvm: string, name: string): string {
const match = llvm.match(
new RegExp(
`define internal [^\\n]+ @sc_(?:b)?f_${name}(?:\\.i32)?\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`,
),
);
expect(match, `${name} LLVM function`).not.toBeNull();
return match![1]!;
}
test("dynamic-tier property sites read and write through inline caches", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-"));
try {
const entry = join(dir, "main.js");
const outPath = join(dir, "main.ir.json");
await writeFile(
entry,
`
function dyn(v) { return v; }
function read(o) { return o.x; }
function write(o, v) { o.x = v; }
function bump(o) { o.n += 1; }
function at(a, i) { return a[i]; }
function call(o) { return o.m(); }
var o = dyn({ x: 1, n: 0, m: function () { return 2; } });
write(o, 3);
bump(o);
console.log(read(o), at(dyn([5, 6]), 1), call(o), o.n);
`,
);
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([]);
expect(moduleUsesDynTier(mod)).toBe(true);
const llvm = emitLlvmModule(mod);
// Each site owns a zeroed thread-local cache record.
expect(llvm).toMatch(
/@sc_js_ic_0 = internal (thread_local )?global \[21 x i64\] zeroinitializer/,
);
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(");
expect(body(llvm, "call")).toContain("@scr_js_load_probe(");
// A compound assignment stores its number result from the jsval: an
// unshared number cell is updated in place.
expect(body(llvm, "bump")).toMatch(/store double %t\d+, ptr %t\d+/);
// The helper stays the out-of-line slow path of every site.
expect(body(llvm, "read")).toMatch(
/@sc_bf__x25_dyn_class_property_\d+\(ptr %t\d+\) noinline cold/,
);
} 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 {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(
entry,
`
interface P { x: number; y: number }
function len2(p: P): number { return p.x * p.x + p.y * p.y; }
const parsed: unknown = JSON.parse('{"x":3,"y":4}');
console.log(len2({ x: 3, y: 4 }), typeof parsed);
`,
);
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(moduleUsesDynTier(mod)).toBe(false);
const llvm = emitLlvmModule(mod);
expect(llvm).not.toContain("@sc_js_ic_");
expect(llvm).not.toContain("@scr_js_");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5685-loop-counter-offset-bounds.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5686-loop-cursor-resets-and-copies.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6022-width-lift-discriminant.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6024-object-literal-method-slot-return.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6148-shape-caches.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6149-jsval-integer-paths.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6251-net-connect-poll-phase-order.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6252-fetch-input-url-unions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6452-promisify-untyped-functions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6571-events-module-class-value.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6572-extends-require-events.cjs"
],
"diags": []
}

Some files were not shown because too many files have changed in this diff Show More