Merge main and keep declared keys in ordered spreads

This commit is contained in:
Chris Tate
2026-10-23 15:20:26 -05:00
526 changed files with 24817 additions and 1862 deletions
+1 -1
View File
@@ -524,7 +524,7 @@ jobs:
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
name: test (tsgo and tsc-ts give the same IR)
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
+1
View File
@@ -33,6 +33,7 @@ output/
/packages/runtime-*/runtime-pack.json
/packages/runtime-*/.runtime-pack-*
!tests/fixtures/npm/node_modules/*/dist/
!tests/fixtures/npm/node_modules/@*/*/dist/
!tests/fixtures/npm/workspace/*/dist/
!tests/fixtures/npm/cases/workspace-copied/members/*/dist/
!tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/
+22 -2
View File
@@ -6,6 +6,28 @@ All notable changes to scriptc will be documented in this file.
<!-- release:start -->
## 0.4.3
### Highlights
- **Faster checking and smaller builds with tsc-ts 0.0.9:** scriptc checks with tsc-ts 0.0.9, and builds reuse the bundled declaration files' diagnostics instead of checking them again. Small IR builds are 10.6 to 16.0% faster and small executable builds 5.5 to 7.6% faster, with identical output. A native build of tsc-ts with this release checks 0.4 to 6.6% faster than with 0.4.2 and peaks 4 to 16% lower.
- **Integer arrays and loops:**
- Number arrays that only hold int32 values use 4-byte slots in locals, across direct calls and in module globals, and loop-carried locals written from their elements stay int32. A workload that walks arrays of cursors runs 54.5% faster.
- Loops with two counters closing in on each other (`for (let i = lo, j = hi; i < j; i++, j--)`) and element copies through `a[i]!` run without per-element checks, and two-counter `while` loops run on integer counters.
- Loops over typed arrays and charAt scans are versioned on their bounds, and ASCII strings are scanned as bytes.
- **Less work for untyped and dynamic code:**
- String-keyed record reads under `??` and `!` no longer allocate: a dictionary-walking workload makes no union boxes instead of 363,359 and runs 12 to 15% faster.
- Checked-dynamic method calls dispatch on method ids, observed class methods call natively while their slot is unchanged, and calls on bag-tracked class instances skip building their views.
- **Node.js parity:**
- `crypto.getCipherInfo`, AES-CCM, AES key wrap, the legacy `createCipheriv` modes, `checkPrime`/`generatePrime`, and Node's argument checks for `generateKeyPair`.
- Streams: `construct`, `writev`, `decodeStrings` and `defaultEncoding`, including `writev` on `Transform` and `PassThrough`.
- `FinalizationRegistry`, `new.target`, `String.prototype.at` on untyped values, `Error.stackTraceLimit`, `spawnSync`'s `env` option, and class constructors called through `call`/`apply` throw Node's TypeError.
- Records enumerate in the key order their object literal wrote, and objects converting to a union with an empty-record arm keep every field.
- **npm packages:** zod 3 compiles natively and zod 4's core runs, minified packages such as marked compile, chalk 5 and hot-shots 8 build from built-in shims, and `@hono/node-server` 2.x is built in. User code types npm package declarations at a checked, identity-preserving boundary.
- **Builds:** the cached build is reused after whitespace and comment edits, function bodies are emitted on forked workers, and the LLVM emitter reads its analyses from one recorded walk of each module.
<!-- release:end -->
## 0.4.2
### Highlights
@@ -159,8 +181,6 @@ All notable changes to scriptc will be documented in this file.
- **The same program compiles to the same executable wherever it sits on disk** and whatever order its functions lower in, and edits outside a declaration do not rename the helpers and closures inside it.
- **Libraries that use untyped byte arrays** link.
<!-- release:end -->
## 0.4.1
### Highlights
+1
View File
@@ -13,6 +13,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
| `dynamic-properties` | cpu | untyped JavaScript reading and writing properties of values it does not type: records built by object literals in two key orders, nested records, and instances of a class and its subclass |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
@@ -0,0 +1,6 @@
{
"entry": "workloads/dynamic-properties.mjs",
"args": [
"1000000"
]
}
@@ -0,0 +1,56 @@
// Untyped JavaScript reading and writing properties of values it never
// declares types for: records built by object literals in two key orders,
// nested records, property updates, and instances of a class and its
// subclass read and written the same way. Argument: the number of
// iterations.
const rounds = Number(process.argv[2] ?? "1000000");
class Cursor {
constructor(x, y) {
this.x = x;
this.y = y;
}
step(dx, dy) {
this.x += dx;
this.y += dy;
return this;
}
}
class Marker extends Cursor {
constructor(x, y, label) {
super(x, y);
this.label = label;
}
}
function makeItem(i) {
if (i % 3 === 0)
return { id: i, price: i % 17, qty: i % 5, meta: { tag: i % 7, weight: i % 11 } };
return { id: i, qty: i % 5, price: i % 13, meta: { weight: i % 9, tag: i % 5 } };
}
function cost(item) {
return item.price * item.qty + item.meta.weight;
}
function restock(item, round) {
item.qty = (item.qty + round) % 7;
item.meta.tag = (item.meta.tag + 1) % 7;
}
function distance(a, b) {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
const items = [];
for (let i = 0; i < 1024; i++) items.push(makeItem(i));
const cursors = [];
for (let i = 0; i < 64; i++)
cursors.push(i % 2 === 0 ? new Cursor(i - 32, 7 - i) : new Marker(i - 32, 7 - i, "m" + i));
const origin = { x: 0, y: 0 };
let checksum = 0;
for (let r = 0; r < rounds; r++) {
const item = items[r & 1023];
checksum += cost(item);
restock(item, r & 3);
checksum += item.meta.tag;
const cursor = cursors[r & 63];
checksum += distance(cursor.step((r % 3) - 1, (r % 5) - 2), origin);
if (cursor.x > 100 || cursor.x < -100) cursor.x = 0;
if (cursor.y > 100 || cursor.y < -100) cursor.y = 0;
}
console.log(checksum, items[5].qty, items[6].meta.tag, cursors[3].x, cursors[3].y, cursors[3].label);
@@ -0,0 +1,6 @@
{
"entry": "workloads/index-cursors.ts",
"args": [
"20000"
]
}
@@ -0,0 +1,25 @@
// Arrays of indices walked by loop-carried cursors: each step reads the
// next index from the array and relinks the visited entries, so the
// cursor and every stored value are small integers read back from the
// array itself.
const N = 4096;
function relink(rounds: number): number {
const next: number[] = [];
for (let i = 0; i < N; i++) next.push(i + 1 < N ? i + 1 : 0);
let at = next[0]!;
let total = 0;
for (let r = 0; r < rounds; r++) {
for (let s = 0; s < N; s++) {
const t = next[at]!;
next[at] = next[t]!;
next[t] = at;
at = t;
total = (total + at) | 0;
}
}
return total;
}
const rounds = Number(process.argv[2] ?? "1");
console.log("relink", relink(rounds));
+8 -1
View File
@@ -53,7 +53,7 @@ Type-only imports follow Node.js's type stripping. `import type` and `export typ
scriptc compiles a package automatically when it:
- ships its own type declarations, or is typed by its DefinitelyTyped `@types/` package, or is a dependency of another package that has no declarations at all, and
- ships readable JavaScript: unminified, or minified by a bundler whose helpers keep their names.
- ships JavaScript that isn't a webpack bundle. Minified JavaScript compiles like unminified JavaScript.
The declarations check your program; the package's JavaScript compiles with the types scriptc infers from it. A program that doesn't typecheck against the inferred types, for example because an `@types/` package describes another release, fails the build at that use.
@@ -92,6 +92,13 @@ scriptc implements a few packages itself and compiles that implementation in pla
- graceful-fs's retry queue, which fs-extra uses under Node.js, isn't part of the implementation, so a program that runs out of file descriptors fails at the first `EMFILE` error instead of retrying.
- An ES module that imports fs-extra can use any of its exports by name. Node.js only exposes the names its CommonJS lexer finds in fs-extra's source, so a name like `readFile` throws a `SyntaxError` there.
`@hono/node-server` 2.x is built in. `serve`, `createAdaptorServer`, `getRequestListener` and `RequestError` serve a fetch handler such as Hono's `app.fetch` over `node:http` with native `Request` and `Response` objects, and write each response the way the package does under Node.js: the same status line, header names, header order and content length, for buffered and streamed bodies, including the package's shortcut for a response that is still exactly what its constructor received. Request bodies stream to the handler as it reads them, and a body the handler leaves unread is drained after the response, up to 64 MiB or 500 ms, as the package does. Known differences:
- Only HTTP/1.1 servers are declared: the `createServer` and `serverOptions` options for `node:https` and `node:http2`, the `websocket` option and `upgradeWebSocket` aren't declared, so using one is a type error at build time. Subpath modules such as `@hono/node-server/serve-static` compile from the package's own files.
- The fetch handler must return a `Response` or a promise of one; the handler's second argument holds `incoming` and `outgoing`.
- The global `Request` and `Response` stay the built-in classes, so `overrideGlobalObjects` has no effect, and a `Response` reads its default text content type as `text/plain;charset=UTF-8` where the package's replacement reads `text/plain; charset=UTF-8`. Written responses carry the package's spelling.
- A streamed response is written without waiting for `drain`, and a failed handler answers 504 only for an error whose `name` is `TimeoutError`.
Small polyfill packages whose module is exactly a Node.js built-in on Node.js 24 compile as that built-in, at the `require()` that loads them, from your program's CommonJS files, from compiled packages, and from esbuild bundles whose module paths name the package and its version (as pnpm installs do). The package's own files aren't compiled.
<table>
@@ -8,7 +8,10 @@
`randomFill`, `randomFillSync` (every view kind and ArrayBuffer), `randomInt`, `randomUUID`, and `randomUUIDv7` follow Node 24.22, including callback timing; a `randomInt` callback whose error parameter is typed `Error | null` receives `null` where Node passes `undefined`.
Scrypt supports its default parameters, numeric options, aliases, and memory limit.
Both KDFs accept strings, Buffers, numeric typed arrays, DataViews, and fixed-length ArrayBuffers.
`createCipheriv` and `createDecipheriv` support aes-128, aes-192, and aes-256 in CBC, CTR, GCM, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede3 in CBC, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede in CBC, ECB, CFB, and OFB modes, chacha20, and chacha20-poly1305, on native targets, through `update()`, `final()`, `setAAD()`, `setAutoPadding()`, `getAuthTag()`, and `setAuthTag()`; other ciphers and wasm32-wasi builds are refused.
`createCipheriv` and `createDecipheriv` support aes-128, aes-192, and aes-256 in CBC, CTR, GCM, CCM, ECB, CFB, CFB8, CFB1, and OFB modes and as key wrap (the wrap and wrap-pad ciphers and their -inv forms), des-ede3 in CBC, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede in CBC, ECB, CFB, and OFB modes, chacha20, and chacha20-poly1305, on native targets, through `update()`, `final()`, `setAAD()`, `setAutoPadding()`, `getAuthTag()`, and `setAuthTag()`; other ciphers and wasm32-wasi builds are refused.
`getCipherInfo` answers Node's info for every cipher OpenSSL names, including the ones without a native implementation.
`getCipherInfo` options whose class defines `keyLength` or `ivLength` throw a coded SC2020 error.
The result is Node's object; the SIV ciphers and the SM4 GCM, CCM, and XTS ciphers have no `nid`, and reading it where the declared `CipherInfo` type gives `number` throws a TypeError.
Cipheriv and Decipheriv values are also Transforms: they can be written, piped into, used as `pipeline()` stages, read, iterated with `for await`, and listened to, and their stream properties read as in Node; written chunks go through `update()`, and the end pushes `final()` or reports its error through `'error'`.
Through `unknown` and `any` slots and untyped JavaScript, in a program that uses streams, their stream properties and their `on`, `once`, `off`, `pipe`, `unpipe`, `read`, `pause`, `resume`, `setEncoding`, `write`, `end`, `destroy`, `cork`, and `uncork` members work; `write` and `end` take no encoding or callback there, and the other stream members throw a coded SC2020 error.
Hash, Hmac, Cipheriv, and Decipheriv streams count a written string's bytes in `writableLength`, where Node counts the string's length.
@@ -29,8 +32,12 @@
Candidates and sizes longer than 262144 bits throw a coded SC2020 error, and wasm32-wasi builds are refused.
Node calls their callbacks with `undefined` as the error, which a callback whose error parameter is typed `Error | null`, as @types/node declares it, receives as `null`.
RSA, EC, and Ed25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
`generateKeyPairSync` and `generateKeyPair` check their arguments in Node's order, with Node's errors, including the OpenSSL errors for RSA modulus lengths below 512 or above 2147483647 bits and for public exponents of 0, 1, or an even number, which `generateKeyPair` passes to its callback.
RSA keys generate with even modulus lengths from 1024 to 8192 bits and public exponents up to 2147483647; other lengths and exponents that Node accepts, and `paramEncoding: 'explicit'`, throw a coded SC2020 error.
The EC curves are prime256v1 (P-256), secp384r1 (P-384), secp521r1 (P-521), and secp256k1.
Keys read and write PEM and DER as PKCS#1, SEC1, PKCS#8, and SubjectPublicKeyInfo, and JWK (Ed25519 keys use PKCS#8, SubjectPublicKeyInfo, and JWK); `createPublicKey` also takes a private key or an X.509 certificate.
EC and Ed25519 keys also read and write the `raw-public` and `raw-private` formats, including compressed EC points.
Raw X25519, X448, Ed448, and post-quantum keys, EC scalars of 0 or at least the curve order, and the EC point at infinity, which Node accepts, throw a coded SC2020 error.
They support `type`, `asymmetricKeyType`, `asymmetricKeyDetails`, `export()`, `equals()`, and `instanceof KeyObject`.
`crypto.sign`, `crypto.verify`, `createSign`, and `createVerify` support RSA PKCS#1 v1.5 and PSS padding and ECDSA with DER and IEEE P1363 encodings over SHA-1 and SHA-2 digests, and Ed25519 through `crypto.sign` and `crypto.verify` with a `null` or `undefined` algorithm.
Ed25519 signing, verification, and private keys link when a string literal in the program names `ed25519` (in any case); without one they throw a coded SC2020 error, while Ed25519 public keys still import and export.
@@ -18,7 +18,7 @@
Stages may take the `{ signal }` argument.
With a function destination, `pipeline()` returns an object-mode PassThrough that receives an async generator destination's values, or an async function destination's value unless it is `null` or `undefined`.
`@types/node` types that result as `NodeJS.WritableStream`, so `for await` over it needs the `PassThrough` type (`pipeline(...) as PassThrough`, or a JSDoc `@type`).
In typed code, the callback of a pipeline with an async function destination receives `null` instead of `undefined` when there is no error.
In typed code, a pipeline callback with an async function or async generator destination receives `null` for no error when its error parameter cannot hold `undefined` (@types/node declares it `NodeJS.ErrnoException | null`), and `undefined` when it can, as in Node.
`Readable.from` accepts `objectMode` and `highWaterMark` options.
The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options.
Their callbacks may take the `{ signal }` argument, `map` may return any value, and `flatMap` may return any iterable, async iterable, or stream.
@@ -27,14 +27,16 @@
A `Duplex.from` body function may take the `{ signal }` argument, which is aborted with the error the Duplex is destroyed with.
`stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it.
A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`.
A web stream in a later compose stage, `Readable.toWeb`, and web `WritableStream` and `TransformStream` interop are unsupported.
`new TransformStream()` without arguments builds the identity transform: its readable side is a web `ReadableStream`, its writable side supports `getWriter`, `abort`, `close`, and `locked`, and the writer supports `write`, `close`, `abort`, `releaseLock`, `ready`, `closed`, and `desiredSize`, with Node's backpressure (a write completes when the readable side reads its chunk).
A web stream in a later compose stage, `Readable.toWeb`, a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough` are unsupported.
The `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream.
`readable.compose(stage, { signal })` is `stream.compose(readable, stage)` with the signal added; the options are an object literal whose `signal` is a variable or a property read.
`pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, with or without function stages, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags.
Stream constructors accept a `signal` option in an options object literal, and `stream.addAbortSignal` accepts any stream.
The `construct` option and `_construct` method, `decodeStrings`, and `defaultEncoding` work on every stream class, and the `writev` option and `_writev` method on Writable and Duplex; `writev` on a Transform or PassThrough, assigning `_construct` after construction, and a non-literal `decodeStrings` or `defaultEncoding` in an options object literal are unsupported.
The `construct` option and `_construct` method, `decodeStrings`, and `defaultEncoding` work on every stream class, and the `writev` option and `_writev` method on every class with a writable side.
A `decodeStrings` or `defaultEncoding` value known only at run time is unsupported after an option member whose value has effects (a call or an assignment).
In typed code, a `writev` entry's `encoding` declared as a string receives `""` where Node passes `undefined` or `null` (an object-mode write).
`setDefaultEncoding()` takes any encoding Node knows.
A `transform` or `flush` option callback whose completion callback types `data` as `any`, as @types/node's `TransformCallback` does, may pass a Buffer, a string, or nothing; any other value throws a TypeError where the callback is called, instead of Node's `ERR_INVALID_ARG_TYPE` stream error.
`node:readline` and `node:readline/promises` (also reached as `readline.promises`) interfaces, from `createInterface(options)` or `createInterface(input, output, completer, terminal)`, read `process.stdin` or a readable stream: `question`, `prompt`, `write` (with a key object literal of `name`, `ctrl`, `meta`, and `shift`), `pause`, `resume`, `'line'`/`'close'`/`'pause'`/`'resume'`/`'error'`/`'history'`/`'SIGINT'`/`'SIGTSTP'`/`'SIGCONT'` listeners, and `for await` compile.
`on`, `once`, and `addListener` return the interface, so registrations chain.
The `history`, `historySize`, `removeHistoryDuplicates`, `completer`, `tabSize`, and `escapeCodeTimeout` options are validated as in Node.
@@ -42,7 +44,7 @@
The output's `'resize'` event does not redraw the line.
The cursor never stops between the two UTF-16 units of a supplementary character: where Node's Up or Down movement through a multiline line would, the cursor moves to the character's start or end.
In JavaScript, a `'history'` listener receives a copy of the history; its changes are written back when the listener returns, but the copy is not `rl.history` itself.
`rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node, except that `rl.cursor` of an interface that is not a terminal throws instead of answering `undefined`.
`rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node; `rl.cursor` is `undefined` on an interface that is not a terminal.
`rl[Symbol.dispose]()` closes the interface, and `instanceof` with `readline.Interface` or `readlinePromises.Interface` answers whether the interface came from that module, as in Node.
In JavaScript, the input and output can be any expression that evaluates to a stream, such as a function's result or an object's member; a value that is not a stream is unsupported.
In JavaScript, `question()` and `setPrompt()` accept a value that is not a string: when such a prompt is written, an interface that is not a terminal throws `ERR_INVALID_ARG_TYPE` as Node does (the `readline/promises` question rejects with it) and the question stays pending, and a terminal interface writes `String(value)`.
@@ -1,6 +1,7 @@
- **Text encoding:** `TextEncoder` and `TextDecoder` can be stored in fields, arguments, return values, arrays, and closures.
Encoding supports UTF-8, including `encodeInto` into a Uint8Array or Buffer view.
Codec properties expose the canonical encoding and decoder flags.
Codecs held in `any` or `unknown` storage keep their identity and streaming state, and dynamic code can call `encode`, `encodeInto` and `decode`, with options objects, and read their properties.
Decoding accepts recognized WHATWG labels, including runtime labels and omitted or undefined encodings.
Literal constructor options support `fatal` and `ignoreBOM`; literal decode options support streaming UTF-8, UTF-16, and single-byte encodings.
Other legacy streaming encodings, runtime options objects, inspection, JSON conversion, and module-scope declarations using `var` or lacking an initializer are unsupported.
+2 -1
View File
@@ -10,9 +10,10 @@
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
"test:unit": "node --test test/compatibility-matrix.test.mjs",
"test:routes": "node scripts/test-routes.mjs",
"compat:check": "pnpm --dir .. node-compat:check",
"check": "pnpm compat:check && pnpm typecheck && pnpm build && pnpm test:routes"
"check": "pnpm compat:check && pnpm typecheck && pnpm test:unit && pnpm build && pnpm test:routes"
},
"dependencies": {
"@vercel/geistdocs": "2.7.8",
+157 -198
View File
@@ -4,52 +4,28 @@
// empty after an edit.
// @refresh reset
import { useEffect, useMemo, useState } from "react";
import { memo, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from "react";
import {
buildTree,
expandableIds,
filterTree,
flattenTree,
nodeCount,
rowMatcher,
searchTexts,
statusCounts,
statuses,
type NodeStability,
type Row,
type Snapshot,
type Status,
type Verification,
} from "@/lib/compatibility-tree";
// Set by next.config.mjs from the committed artifact.
const artifactVersion = process.env.COMPATIBILITY_ARTIFACT_VERSION;
const rowCount = Number(process.env.COMPATIBILITY_ROW_COUNT);
type Status =
| "supported"
| "partial"
| "refused"
| "not-implemented"
| "by-design"
| "unreviewed"
| "not-applicable";
type VerificationBasis =
| "test-backed"
| "explicit-refusal"
| "verified-absence"
| "declared-gap"
| "registry-gap"
| "architectural-policy"
| "unreviewed"
| "not-applicable"
| "derived";
type Verification = { label: string; detail: string };
type Tier = { status: Status; detail: string; verification: VerificationBasis };
type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
type Row = {
id: string;
chapter: string;
kind: string;
name: string;
signature: string;
label: string;
apiSymbol: string;
depth: number;
scope: "api" | "documentation" | "metadata" | "configuration";
anchor: string;
nodeStability: NodeStability;
static: Tier;
};
type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
type Snapshot = { nodeVersion: string; verificationBases: Record<VerificationBasis, Verification>; chapters: Chapter[]; rows: Row[] };
type TreeNode = { row: Row; children: TreeNode[] };
type VisibleNode = { node: TreeNode; level: number };
const labels: Record<Status, string> = {
supported: "Supported",
partial: "Partial",
@@ -70,7 +46,6 @@ const styles: Record<Status, string> = {
"not-applicable": "border-gray-alpha-400 bg-background-200 text-gray-700",
};
const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
const stabilityStyles: Record<number, string> = {
0: "border-red-500/40 bg-red-100 text-red-900 dark:bg-red-200",
1: "border-amber-500/50 bg-amber-100 text-amber-900 dark:bg-amber-200",
@@ -78,114 +53,83 @@ const stabilityStyles: Record<number, string> = {
3: "border-gray-alpha-500 bg-gray-alpha-100 text-gray-900",
};
function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
/** A badge's tooltip, mounted only while the badge is hovered or has
* keyboard focus (the :focus-visible rule the CSS-only tooltip used), and
* named by aria-describedby while open. Hover and focus are tracked apart,
* so moving the pointer away keeps a keyboard-focused badge's tooltip.
* Escape dismisses it until the next hover or focus. */
function BadgeTooltip({ tooltipClass, children, badge }: { tooltipClass: string; children: ReactNode; badge: ReactNode }) {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const open = hovered || focused;
const id = useId();
const enter = useCallback(() => setHovered(true), []);
const leave = useCallback(() => setHovered(false), []);
const focus = useCallback((event: FocusEvent<HTMLSpanElement>) => {
if (event.currentTarget.matches(":focus-visible")) setFocused(true);
}, []);
const blur = useCallback(() => setFocused(false), []);
const key = useCallback((event: KeyboardEvent<HTMLSpanElement>) => {
if (event.key !== "Escape") return;
setHovered(false);
setFocused(false);
}, []);
return (
<span className="group relative inline-flex" tabIndex={tooltip ? 0 : undefined}>
<span
className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${styles[status]}`}
title={tooltip}
>
{labels[status]}
</span>
{tooltip ? (
<span role="tooltip" className="pointer-events-none invisible absolute left-0 top-full z-30 mt-2 w-64 rounded-md border border-gray-alpha-500 bg-background-100 p-2 text-left text-xs font-normal leading-5 text-gray-1000 opacity-0 shadow-popover transition-opacity group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100">
{detail ? <span className="block">{detail}</span> : null}
{verification ? <span className={detail ? "mt-2 block border-t border-gray-alpha-400 pt-2" : "block"}><strong>Verification:</strong> {verification.label}. {verification.detail}</span> : null}
<span
className="relative inline-flex"
tabIndex={0}
aria-describedby={open ? id : undefined}
onMouseEnter={enter}
onMouseLeave={leave}
onFocus={focus}
onBlur={blur}
onKeyDown={key}
>
{badge}
{open ? (
<span id={id} role="tooltip" className={`pointer-events-none absolute top-full z-30 mt-2 rounded-md border border-gray-alpha-500 bg-background-100 p-2 text-left text-xs font-normal leading-5 text-gray-1000 opacity-100 shadow-popover transition-opacity starting:opacity-0 ${tooltipClass}`}>
{children}
</span>
) : null}
</span>
);
}
function StabilityBadge({ stability }: { stability: NodeStability }) {
export const StatusBadge = memo(function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
const badge = (
<span
className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${styles[status]}`}
title={tooltip}
>
{labels[status]}
</span>
);
if (!tooltip) return <span className="relative inline-flex">{badge}</span>;
return (
<BadgeTooltip tooltipClass="left-0 w-64" badge={badge}>
{detail ? <span className="block">{detail}</span> : null}
{verification ? <span className={detail ? "mt-2 block border-t border-gray-alpha-400 pt-2" : "block"}><strong>Verification:</strong> {verification.label}. {verification.detail}</span> : null}
</BadgeTooltip>
);
});
export const StabilityBadge = memo(function StabilityBadge({ stability }: { stability: NodeStability }) {
if (stability === null) return <span className="text-xs text-gray-700" title="Node.js does not publish a stability index for this entry.">—</span>;
const detail = `Node.js Stability ${stability.index}: ${stability.text}${stability.inherited ? " (inherited from the nearest parent section)" : ""}`;
return (
<span className="group relative inline-flex" tabIndex={0}>
<span className={`inline-flex min-w-7 justify-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${stabilityStyles[stability.level] ?? stabilityStyles[3]}`} title={detail}>
{stability.index}
</span>
<span role="tooltip" className="pointer-events-none invisible absolute left-1/2 top-full z-30 mt-2 w-72 -translate-x-1/2 rounded-md border border-gray-alpha-500 bg-background-100 p-2 text-left text-xs font-normal leading-5 text-gray-1000 opacity-0 shadow-popover transition-opacity group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100">
{detail}
</span>
</span>
);
}
function statusCounts(rows: Row[], tier: "static") {
return rows.reduce<Record<Status, number>>(
(counts, row) => {
if (row.scope !== "api" || row.depth === 0) return counts;
counts[row[tier].status] += 1;
return counts;
},
{
supported: 0,
partial: 0,
refused: 0,
"not-implemented": 0,
"by-design": 0,
unreviewed: 0,
"not-applicable": 0,
},
);
}
/** Node's all.json is preorder with an explicit heading depth. */
function buildTree(rows: Row[]): TreeNode[] {
const roots: TreeNode[] = [];
const stack: TreeNode[] = [];
for (const row of rows) {
const node: TreeNode = { row, children: [] };
while (stack.length > row.depth) stack.pop();
const parent = stack[row.depth - 1];
if (row.depth === 0 || parent === undefined) roots.push(node);
else parent.children.push(node);
stack[row.depth] = node;
stack.length = row.depth + 1;
}
return roots;
}
function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): TreeNode[] {
const filtered: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, matches);
if (matches(node.row) || children.length > 0) filtered.push({ row: node.row, children });
}
return filtered;
}
function flattenTree(nodes: TreeNode[], expanded: Set<string>, forceExpanded: boolean): VisibleNode[] {
const visible: VisibleNode[] = [];
const visit = (items: TreeNode[], level: number) => {
for (const node of items) {
visible.push({ node, level });
if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
visit(node.children, level + 1);
<BadgeTooltip
tooltipClass="left-1/2 w-72 -translate-x-1/2"
badge={
<span className={`inline-flex min-w-7 justify-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${stabilityStyles[stability.level] ?? stabilityStyles[3]}`} title={detail}>
{stability.index}
</span>
}
}
};
visit(nodes, 1);
return visible;
}
function nodeCount(nodes: TreeNode[]): number {
return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
}
function expandableIds(nodes: TreeNode[]): Set<string> {
const ids = new Set<string>();
const visit = (items: TreeNode[]) => {
for (const node of items) {
if (node.children.length > 0) ids.add(node.row.id);
visit(node.children);
}
};
visit(nodes);
return ids;
}
>
{detail}
</BadgeTooltip>
);
});
function Chevron({ open }: { open: boolean }) {
return (
@@ -195,6 +139,47 @@ function Chevron({ open }: { open: boolean }) {
);
}
type MatrixRowProps = {
row: Row;
level: number;
hasChildren: boolean;
open: boolean;
filtering: boolean;
displayName: string;
href: string;
verification: Verification | undefined;
onToggle: (id: string) => void;
};
/** One table row. Memoized on its own values, so a filter or expansion
* change re-renders only the rows whose values changed. */
const MatrixRow = memo(function MatrixRow({ row, level, hasChildren, open, filtering, displayName, href, verification, onToggle }: MatrixRowProps) {
const isRoot = level === 1;
return (
<tr aria-level={level} aria-expanded={hasChildren ? open : undefined} className={`align-top hover:bg-gray-alpha-100/60 ${isRoot ? "bg-background-200/60" : ""}`}>
<td>
<div className="flex items-start gap-1.5" style={{ paddingLeft: `${(level - 1) * 18}px` }}>
{hasChildren ? (
<button type="button" onClick={() => onToggle(row.id)} disabled={filtering} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
<Chevron open={open} />
</button>
) : <span className="h-5 w-5 shrink-0" />}
<div className="min-w-0">
<a href={href} target="_blank" rel="noopener noreferrer" className={`${isRoot ? "font-sans text-sm font-medium" : "font-mono text-xs"} text-gray-1000 underline decoration-gray-alpha-500 underline-offset-2 hover:decoration-gray-1000`}>
{displayName}
</a>
<div className="mt-1 text-[11px] text-gray-800">
{row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
</div>
</div>
</div>
</td>
<td className="text-center"><StabilityBadge stability={row.nodeStability} /></td>
<td><StatusBadge status={row.static.status} detail={row.static.detail} verification={verification} /></td>
</tr>
);
});
export function NodeCompatibilityMatrix() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [loadError, setLoadError] = useState(false);
@@ -202,15 +187,16 @@ export function NodeCompatibilityMatrix() {
const [staticFilter, setStaticFilter] = useState<"all" | Status>("all");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const rows = snapshot?.rows ?? [];
const chapters = snapshot?.chapters ?? [];
const rows = useMemo(() => snapshot?.rows ?? [], [snapshot]);
const chapters = useMemo(() => snapshot?.chapters ?? [], [snapshot]);
const verificationBases = snapshot?.verificationBases;
const apiRowCount = useMemo(() => rows.filter((row) => row.scope === "api").length, [rows]);
const chapterTitles = useMemo(() => new Map(chapters.map((item) => [item.slug, item.title])), [chapters]);
const base = `https://nodejs.org/docs/v${snapshot?.nodeVersion ?? "24.22.0"}/api/`;
const staticCounts = useMemo(() => statusCounts(rows, "static"), [rows]);
const staticCounts = useMemo(() => statusCounts(rows), [rows]);
const visibleStatuses = useMemo(() => statuses.filter((status) => staticCounts[status] > 0), [staticCounts]);
const tree = useMemo(() => buildTree(rows), [rows]);
const texts = useMemo(() => searchTexts(rows, chapterTitles), [rows, chapterTitles]);
useEffect(() => {
const controller = new AbortController();
@@ -231,36 +217,23 @@ export function NodeCompatibilityMatrix() {
return () => controller.abort();
}, []);
const needle = query.trim().toLocaleLowerCase();
const hasResultFilter = needle !== "" || staticFilter !== "all";
const rowMatches = (row: Row) => {
if (staticFilter !== "all" && row.static.status !== staticFilter) return false;
if (!needle) return true;
return [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
.join(" ")
.toLocaleLowerCase()
.includes(needle);
};
// The search field and the status select update at once; the table
// follows the deferred values, so input stays responsive while the rows
// re-render.
const deferredQuery = useDeferredValue(query);
const deferredStatic = useDeferredValue(staticFilter);
const needle = deferredQuery.trim().toLocaleLowerCase();
const hasResultFilter = needle !== "" || deferredStatic !== "all";
const resultTree = useMemo(
() => hasResultFilter ? filterTree(tree, rowMatches) : tree,
// rowMatches closes over these filter values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasResultFilter, needle, staticFilter, tree],
const result = useMemo(
() => hasResultFilter ? filterTree(tree, rowMatcher(texts, needle, deferredStatic)) : { nodes: tree, matched: nodeCount(tree) },
[hasResultFilter, needle, deferredStatic, texts, tree],
);
const visible = useMemo(
() => flattenTree(resultTree, expanded, hasResultFilter),
[expanded, hasResultFilter, resultTree],
);
const matchedCount = useMemo(
() => hasResultFilter ? tree.reduce((count, node) => {
const visit = (item: TreeNode): number => (rowMatches(item.row) ? 1 : 0) + item.children.reduce((sum, child) => sum + visit(child), 0);
return count + visit(node);
}, 0) : nodeCount(tree),
// rowMatches closes over these filter values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasResultFilter, needle, staticFilter, tree],
() => flattenTree(result.nodes, expanded, hasResultFilter),
[expanded, hasResultFilter, result],
);
const matchedCount = result.matched;
const changeQuery = (value: string) => setQuery(value);
const resetFilters = () => {
@@ -268,12 +241,12 @@ export function NodeCompatibilityMatrix() {
setStaticFilter("all");
setExpanded(new Set());
};
const toggle = (id: string) => setExpanded((current) => {
const toggle = useCallback((id: string) => setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}), []);
useEffect(() => {
if (!snapshot) return;
@@ -316,8 +289,8 @@ export function NodeCompatibilityMatrix() {
<span aria-live="polite">{visible.length.toLocaleString()} visible · {matchedCount.toLocaleString()} {hasResultFilter ? "matching" : "API"} rows</span>
{!hasResultFilter ? (
<span className="flex gap-1">
<button type="button" onClick={() => setExpanded(expandableIds(tree))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all</button>
<button type="button" onClick={() => setExpanded(new Set())} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all</button>
<button type="button" onClick={() => startTransition(() => setExpanded(expandableIds(tree)))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all</button>
<button type="button" onClick={() => startTransition(() => setExpanded(new Set()))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all</button>
</span>
) : null}
</div>
@@ -335,33 +308,19 @@ export function NodeCompatibilityMatrix() {
<tbody>
{visible.map(({ node, level }) => {
const row = node.row;
const hasChildren = node.children.length > 0;
const open = hasResultFilter || expanded.has(row.id);
const href = `${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`;
const isRoot = level === 1;
const displayName = isRoot ? chapterTitles.get(row.chapter) ?? row.label : row.label;
return (
<tr key={row.id} aria-level={level} aria-expanded={hasChildren ? open : undefined} className={`align-top hover:bg-gray-alpha-100/60 ${isRoot ? "bg-background-200/60" : ""}`}>
<td>
<div className="flex items-start gap-1.5" style={{ paddingLeft: `${(level - 1) * 18}px` }}>
{hasChildren ? (
<button type="button" onClick={() => toggle(row.id)} disabled={hasResultFilter} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
<Chevron open={open} />
</button>
) : <span className="h-5 w-5 shrink-0" />}
<div className="min-w-0">
<a href={href} target="_blank" rel="noopener noreferrer" className={`${isRoot ? "font-sans text-sm font-medium" : "font-mono text-xs"} text-gray-1000 underline decoration-gray-alpha-500 underline-offset-2 hover:decoration-gray-1000`}>
{displayName}
</a>
<div className="mt-1 text-[11px] text-gray-800">
{row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
</div>
</div>
</div>
</td>
<td className="text-center"><StabilityBadge stability={row.nodeStability} /></td>
<td><StatusBadge status={row.static.status} detail={row.static.detail} verification={verificationBases?.[row.static.verification]} /></td>
</tr>
<MatrixRow
key={row.id}
row={row}
level={level}
hasChildren={node.children.length > 0}
open={hasResultFilter || expanded.has(row.id)}
filtering={hasResultFilter}
displayName={level === 1 ? chapterTitles.get(row.chapter) ?? row.label : row.label}
href={`${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`}
verification={verificationBases?.[row.static.verification]}
onToggle={toggle}
/>
);
})}
</tbody>
@@ -11660,9 +11660,9 @@
"inherited": true
},
"static": {
"status": "refused",
"detail": "Explicitly rejected by a named compiler diagnostic.",
"verification": "explicit-refusal"
"status": "partial",
"detail": "Implemented for the call shapes accepted by the compiler lowering.",
"verification": "test-backed"
}
},
{
@@ -35971,9 +35971,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -81657,9 +81657,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented static async-iteration subset.",
"verification": "test-backed"
}
},
{
+157
View File
@@ -0,0 +1,157 @@
// The compatibility matrix's data model: the ledger rows as a tree, and the
// search and filter passes over it. Kept free of React so the passes can be
// tested directly.
export type Status =
| "supported"
| "partial"
| "refused"
| "not-implemented"
| "by-design"
| "unreviewed"
| "not-applicable";
export type VerificationBasis =
| "test-backed"
| "explicit-refusal"
| "verified-absence"
| "declared-gap"
| "registry-gap"
| "architectural-policy"
| "unreviewed"
| "not-applicable"
| "derived";
export type Verification = { label: string; detail: string };
export type Tier = { status: Status; detail: string; verification: VerificationBasis };
export type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
export type Row = {
id: string;
chapter: string;
kind: string;
name: string;
signature: string;
label: string;
apiSymbol: string;
depth: number;
scope: "api" | "documentation" | "metadata" | "configuration";
anchor: string;
nodeStability: NodeStability;
static: Tier;
};
export type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
export type Snapshot = {
nodeVersion: string;
verificationBases: Record<VerificationBasis, Verification>;
chapters: Chapter[];
rows: Row[];
};
export type TreeNode = { row: Row; children: TreeNode[] };
export type VisibleNode = { node: TreeNode; level: number };
export const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
export function statusCounts(rows: Row[]): Record<Status, number> {
return rows.reduce<Record<Status, number>>(
(counts, row) => {
if (row.scope !== "api" || row.depth === 0) return counts;
counts[row.static.status] += 1;
return counts;
},
{
supported: 0,
partial: 0,
refused: 0,
"not-implemented": 0,
"by-design": 0,
unreviewed: 0,
"not-applicable": 0,
},
);
}
/** Node's all.json is preorder with an explicit heading depth. */
export function buildTree(rows: Row[]): TreeNode[] {
const roots: TreeNode[] = [];
const stack: TreeNode[] = [];
for (const row of rows) {
const node: TreeNode = { row, children: [] };
while (stack.length > row.depth) stack.pop();
const parent = stack[row.depth - 1];
if (row.depth === 0 || parent === undefined) roots.push(node);
else parent.children.push(node);
stack[row.depth] = node;
stack.length = row.depth + 1;
}
return roots;
}
/** The lowercase text a search matches against, built once per row: the
* chapter title, kind, name, signature, API symbol and support detail. */
export function searchTexts(rows: Row[], chapterTitles: ReadonlyMap<string, string>): Map<string, string> {
return new Map(
rows.map((row) => [
row.id,
[chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
.join(" ")
.toLocaleLowerCase(),
]),
);
}
/** The rows a status filter and a lowercase search needle keep. */
export function rowMatcher(
texts: ReadonlyMap<string, string>,
needle: string,
status: "all" | Status,
): (row: Row) => boolean {
return (row) => {
if (status !== "all" && row.static.status !== status) return false;
return needle === "" || (texts.get(row.id) ?? "").includes(needle);
};
}
/** The matching rows with their ancestors, and the number of matching
* rows, in one pass. */
export function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): { nodes: TreeNode[]; matched: number } {
let matched = 0;
const visit = (items: TreeNode[]): TreeNode[] => {
const filtered: TreeNode[] = [];
for (const node of items) {
const children = visit(node.children);
const own = matches(node.row);
if (own) matched += 1;
if (own || children.length > 0) filtered.push({ row: node.row, children });
}
return filtered;
};
return { nodes: visit(nodes), matched };
}
export function flattenTree(nodes: TreeNode[], expanded: ReadonlySet<string>, forceExpanded: boolean): VisibleNode[] {
const visible: VisibleNode[] = [];
const visit = (items: TreeNode[], level: number) => {
for (const node of items) {
visible.push({ node, level });
if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
visit(node.children, level + 1);
}
}
};
visit(nodes, 1);
return visible;
}
export function nodeCount(nodes: TreeNode[]): number {
return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
}
export function expandableIds(nodes: TreeNode[]): Set<string> {
const ids = new Set<string>();
const visit = (items: TreeNode[]) => {
for (const node of items) {
if (node.children.length > 0) ids.add(node.row.id);
visit(node.children);
}
};
visit(nodes);
return ids;
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ts from "typescript";
import {
buildTree,
expandableIds,
filterTree,
flattenTree,
nodeCount,
rowMatcher,
searchTexts,
statusCounts,
} from "../src/lib/compatibility-tree.ts";
const row = (id, depth, label, status, detail = "") => ({
id,
chapter: "zlib",
kind: depth === 0 ? "module" : "method",
name: label,
signature: label,
label,
apiSymbol: label,
depth,
scope: "api",
anchor: "",
nodeStability: null,
static: { status, detail, verification: "test-backed" },
});
const rows = [
row("root", 0, "zlib", "partial"),
row("a", 1, "zlib.gzipSync()", "supported", "Lowered with Node's options"),
row("b", 1, "zlib.Gzip", "partial"),
row("b1", 2, "gzip.flush()", "supported"),
row("c", 1, "zlib.zstdCompress()", "not-implemented"),
];
const titles = new Map([["zlib", "Zlib"]]);
test("the tree follows the rows' heading depths", () => {
const tree = buildTree(rows);
assert.equal(tree.length, 1);
assert.deepEqual(tree[0].children.map((node) => node.row.id), ["a", "b", "c"]);
assert.deepEqual(tree[0].children[1].children.map((node) => node.row.id), ["b1"]);
assert.equal(nodeCount(tree), 5);
assert.deepEqual([...expandableIds(tree)].sort(), ["b", "root"]);
});
test("filtering keeps matching rows with their ancestors and counts the matches once", () => {
const tree = buildTree(rows);
const texts = searchTexts(rows, titles);
const byText = filterTree(tree, rowMatcher(texts, "flush", "all"));
assert.equal(byText.matched, 1);
assert.deepEqual(flattenTree(byText.nodes, new Set(), true).map(({ node, level }) => `${node.row.id}@${level}`), ["root@1", "b@2", "b1@3"]);
const byDetail = filterTree(tree, rowMatcher(texts, "node's options", "all"));
assert.deepEqual(flattenTree(byDetail.nodes, new Set(), true).map(({ node }) => node.row.id), ["root", "a"]);
const byStatus = filterTree(tree, rowMatcher(texts, "", "supported"));
assert.equal(byStatus.matched, 2);
const both = filterTree(tree, rowMatcher(texts, "gzip", "partial"));
assert.equal(both.matched, 1);
assert.equal(filterTree(tree, rowMatcher(texts, "brotli", "all")).nodes.length, 0);
});
test("collapsed rows hide their children until expanded", () => {
const tree = buildTree(rows);
assert.deepEqual(flattenTree(tree, new Set(), false).map(({ node }) => node.row.id), ["root"]);
assert.deepEqual(flattenTree(tree, new Set(["root"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "c"]);
assert.deepEqual(flattenTree(tree, new Set(["root", "b"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "b1", "c"]);
});
test("status counts cover API rows below the chapters", () => {
const counts = statusCounts(rows);
assert.equal(counts.supported, 2);
assert.equal(counts.partial, 1);
assert.equal(counts["not-implemented"], 1);
});
/** The matrix component, compiled for Node from its TSX source. */
async function loadMatrix() {
const docs = new URL("../", import.meta.url);
const source = await readFile(new URL("src/components/node-compatibility-matrix.tsx", docs), "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
});
const lib = pathToFileURL(fileURLToPath(new URL("src/lib/compatibility-tree.ts", docs))).href;
const dir = new URL("node_modules/.cache/compatibility-matrix-test/", docs);
await mkdir(dir, { recursive: true });
const file = new URL("node-compatibility-matrix.mjs", dir);
await writeFile(file, outputText.replace('"@/lib/compatibility-tree"', JSON.stringify(lib)));
return import(file.href);
}
test("badges render without their tooltips until hovered or focused", async () => {
const { StatusBadge, StabilityBadge } = await loadMatrix();
const status = renderToStaticMarkup(
createElement(StatusBadge, {
status: "partial",
detail: "Lowered with Node's options",
verification: { label: "Test-backed", detail: "Corpus programs cover it." },
}),
);
assert.ok(status.includes('tabindex="0"'), status);
assert.ok(status.includes('title="Lowered with Node&#x27;s options'), status);
assert.ok(!status.includes('role="tooltip"'), status);
assert.ok(!status.includes("aria-describedby"), status);
const stability = renderToStaticMarkup(
createElement(StabilityBadge, { stability: { index: "2", level: 2, text: "Stable", inherited: false } }),
);
assert.ok(stability.includes('tabindex="0"'), stability);
assert.ok(!stability.includes('role="tooltip"'), stability);
const legend = renderToStaticMarkup(createElement(StatusBadge, { status: "supported" }));
assert.ok(!legend.includes("tabindex"), legend);
});
@@ -19332,6 +19332,10 @@
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/7152-crypto-prime.ts",
"tests/corpus/7153-crypto-prime-errors.cjs",
"tests/corpus/7154-crypto-cipher-info.cjs",
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7157-crypto-cipher-info-typed.ts",
"tests/corpus/node24-crypto-kdf-errors.ts",
"tests/corpus/node24-crypto-kdf-options.ts",
"tests/corpus/node24-crypto-kdf.ts",
@@ -19719,6 +19723,8 @@
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.createCipheriv",
"tests": [
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7150-cipher-legacy-modes.ts",
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/5250-cipher-aes-gcm.ts",
@@ -19760,6 +19766,8 @@
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.createDecipheriv",
"tests": [
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7150-cipher-legacy-modes.ts",
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/5250-cipher-aes-gcm.ts",
@@ -20434,8 +20442,12 @@
"inherited": true
},
"static": {
"status": "refused",
"evidence": "surface-manifest:unsupported:node-builtin.crypto.getCipherInfo"
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.getCipherInfo",
"tests": [
"tests/corpus/7154-crypto-cipher-info.cjs",
"tests/corpus/7157-crypto-cipher-info-typed.ts"
]
},
"anchorSource": "exact"
},
@@ -52182,6 +52194,8 @@
"tests/corpus/6294-webcrypto-js-values.mjs",
"tests/corpus/6295-jose-hs256/main.mjs",
"tests/corpus/6331-jose-root-entry/main.ts",
"tests/corpus/7743-transform-stream-identity.ts",
"tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/text-codec-values/main.ts",
"tests/corpus/webassembly-api.mjs",
"tests/corpus/webassembly-dylink/main.mjs",
@@ -53334,7 +53348,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53385,7 +53400,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53431,8 +53447,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:globals"
"status": "partial",
"evidence": "compiler-feature:globals.TransformStream",
"tests": [
"tests/corpus/7743-transform-stream-identity.ts"
]
},
"anchorSource": "exact"
},
@@ -101265,6 +101284,7 @@
"tests/corpus/5835-util-format-integer-specifiers.ts",
"tests/corpus/5884-inspect-layout-options.ts",
"tests/corpus/6428-promisify-callback-functions.ts",
"tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/node24-deep-equality-properties.js",
"tests/corpus/node24-deep-equality.cjs",
"tests/corpus/node24-deep-equality.ts",
@@ -101793,7 +101813,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -101914,7 +101935,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102024,7 +102046,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102053,7 +102076,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
"tests/corpus/3133-text-decoder-streaming.ts"
"tests/corpus/3133-text-decoder-streaming.ts",
"tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -111471,6 +111495,7 @@
"tests/corpus/2630-stream-consumers-errors.ts",
"tests/corpus/2862-web-readable-async-iteration.ts",
"tests/corpus/4875-stream-consumers-array-buffer.ts",
"tests/corpus/7743-transform-stream-identity.ts",
"tests/corpus/stream-web-readable.ts",
"tests/corpus/stream-web-require.cjs",
"tests/harness/fetch-conformance.test.ts"
@@ -112945,8 +112970,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:webstreams"
"status": "partial",
"evidence": "compiler-feature:webstreams.TransformStream",
"tests": [
"tests/corpus/7743-transform-stream-identity.ts"
]
},
"anchorSource": "exact"
},
+8 -5
View File
@@ -44,8 +44,8 @@
"node-builtin.console": ["tests/corpus/console-imports.cjs", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/console-native-value.js", "tests/harness/npm-static.test.ts"],
"node-builtin.crypto.checkPrime": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.checkPrimeSync": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.createCipheriv": ["tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDecipheriv": ["tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createCipheriv": ["tests/corpus/7155-cipher-aes-ccm.cjs", "tests/corpus/7156-cipher-key-wrap.cjs", "tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDecipheriv": ["tests/corpus/7155-cipher-aes-ccm.cjs", "tests/corpus/7156-cipher-key-wrap.cjs", "tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDiffieHellman": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.createDiffieHellmanGroup": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.createECDH": ["tests/corpus/6700-ecdh-shared-secret.ts", "tests/corpus/6701-ecdh-formats.ts", "tests/corpus/6702-ecdh-errors.js", "tests/corpus/6703-ecdh-private-keys.ts", "tests/corpus/6704-ecdh-set-public-key.cjs", "tests/corpus/6705-ecdh-handles.ts", "tests/fixtures/node-types/ecdh.ts"],
@@ -124,6 +124,7 @@
"node-builtin.fs.constants": ["tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs"],
"node-builtin.crypto.generatePrime": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.generatePrimeSync": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.getCipherInfo": ["tests/corpus/7154-crypto-cipher-info.cjs", "tests/corpus/7157-crypto-cipher-info-typed.ts"],
"node-builtin.crypto.getDiffieHellman": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.getRandomValues": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"],
"node-builtin.url.parse": ["tests/corpus/5220-url-parse-warnings.ts", "tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"],
@@ -331,11 +332,11 @@
{ "chapter": "buffer", "signatures": ["`buf.readBigInt64BE([offset])`", "`buf.readBigInt64LE([offset])`", "`buf.readBigUInt64BE([offset])`", "`buf.readBigUInt64LE([offset])`", "`buf.writeBigInt64BE(value[, offset])`", "`buf.writeBigInt64LE(value[, offset])`", "`buf.writeBigUInt64BE(value[, offset])`", "`buf.writeBigUInt64LE(value[, offset])`"], "status": "supported", "evidence": ["tests/corpus/2902-bigint-bytes.ts", "tests/corpus/2904-bigint-errors.ts"] },
{ "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" },
{ "chapter": "globals", "symbols": ["fetch"], "status": "partial", "evidence": ["tests/harness/fetch-conformance.test.ts"] },
{ "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
{ "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "globals", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5287-blob-inspect.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5290-blob-dynamic.js"] },
{ "chapter": "buffer", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5288-blob-validation.js", "tests/fixtures/node-types/blob-file.ts"] },
{ "chapter": "buffer", "signatures": ["`blob.arrayBuffer()`", "`blob.bytes()`", "`blob.text()`", "`blob.slice([start[, end[, type]]])`", "`blob.size`", "`type` Type: {string}", "`name` Type: {string}", "`lastModified` Type: {number}"], "status": "supported", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5289-blob-read-order.ts", "tests/corpus/5290-blob-dynamic.js", "tests/fixtures/node-types/blob-file.ts"] },
{ "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
{ "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "modules", "symbols": ["__dirname", "__filename", "exports", "require", "module.require", "main", "filename"], "status": "partial", "evidence": ["tests/corpus/1612-cjs-module-globals.cjs", "tests/corpus/1596-cjs-modules/main.js", "tests/corpus/1629-require-main-filename.cjs", "tests/corpus/2830-cjs-require-self-name/main.cjs", "tests/corpus/2880-cjs-module-graph/main.cjs", "tests/corpus/2923-cjs-factory-export/main.cjs"] },
{ "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths"], "status": "partial", "evidence": ["tests/corpus/2858-import-meta-resolve/main.mjs", "tests/corpus/2860-require-resolve/main.cjs"] },
{ "chapter": "modules", "symbols": ["cache", "children", "id", "isPreloading", "loaded", "parent", "path", "paths", "Module"], "status": "partial", "evidence": ["tests/corpus/2880-cjs-module-graph/main.cjs"] },
@@ -406,7 +407,9 @@
{ "chapter": "zlib", "signatures": ["`zlib.constants`"], "status": "partial", "evidence": ["tests/corpus/5376-zlib-constants.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts"] },
{ "chapter": "dns", "symbols": ["dns.lookup", "dns.lookupService", "dns.getServers", "dns.getDefaultResultOrder", "dns.setDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/5648-dns-lookup-forms.ts", "tests/corpus/5649-dns-service-servers.ts", "tests/corpus/5650-dns-lookup-errors-untyped.cjs"] },
{ "chapter": "dns", "symbols": ["dnsPromises.lookup", "dnsPromises.lookupService", "dnsPromises.getDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/dns-promises-lookup.ts", "tests/corpus/dns-promises-untyped.cjs"] },
{ "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] }
{ "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] },
{ "chapter": "webstreams", "symbols": ["TransformStream"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] },
{ "chapter": "globals", "signatures": ["Class: `TransformStream`"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] }
],
"dedicated": [
{ "symbols": ["assert.doesNotThrow", "assert.doesNotReject"], "status": "partial", "evidence": ["tests/corpus/node24-no-error-assertions.ts", "tests/corpus/node24-no-error-assertions.cjs", "tests/corpus/node24-no-error-assertions-coercion.js", "tests/fixtures/node-types/no-error-assertions.ts", "tests/harness/project-config.test.ts"] },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-arm64",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-x64",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-musl",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
"version": "0.4.2",
"version": "0.4.3",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "scriptc",
"version": "0.4.2",
"version": "0.4.3",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 36, marker: "scr_runtime_abi_v36" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v36");
expect(error?.stderr).toContain("scr_runtime_abi_v37");
});
});
+222 -9
View File
@@ -1106,6 +1106,34 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream<string>;
readonly prototype: ReadableStream<unknown>;
};
/* The writable side of a TransformStream and its writer. The
* WritableStream constructor has no native lowering; writable streams come
* from `new TransformStream()`. */
interface WritableStreamDefaultWriter<W = unknown> {
readonly closed: Promise<void>;
readonly desiredSize: number | null;
readonly ready: Promise<void>;
abort(reason?: unknown): Promise<void>;
close(): Promise<void>;
releaseLock(): void;
write(chunk?: W): Promise<void>;
}
interface WritableStream<W = unknown> {
readonly locked: boolean;
abort(reason?: unknown): Promise<void>;
close(): Promise<void>;
getWriter(): WritableStreamDefaultWriter<W>;
}
/* The identity transform: chunks written to `writable` are read from
* `readable`, and a write completes when its chunk is read. */
interface TransformStream<I = unknown, O = unknown> {
readonly readable: ReadableStream<O>;
readonly writable: WritableStream<I>;
}
declare var TransformStream: {
new <I = unknown, O = I>(): TransformStream<I, O>;
readonly prototype: TransformStream<unknown, unknown>;
};
/* fetch, typed as the native JSON + readable-stream slice: one URL, an
* optional init with the members CLI requests carry, and a Response
@@ -3171,13 +3199,17 @@ declare module "crypto" {
callback: (error: Error | null, buffer: Buffer) => void,
): void;
export type BinaryLike = string | ArrayBuffer | ArrayBufferView;
/* Cipheriv/Decipheriv: aes-128/192/256 in cbc, ctr, gcm, ecb, cfb, cfb8,
* cfb1, and ofb modes, des-ede3 and des-ede, chacha20, and
* chacha20-poly1305. Both are Transforms: written chunks go through
* update(), and the end pushes final(). */
/* Cipheriv/Decipheriv: aes-128/192/256 in cbc, ctr, gcm, ccm, ecb, cfb,
* cfb8, cfb1, and ofb modes and their key wraps, des-ede3 and des-ede,
* chacha20, and chacha20-poly1305. Both are Transforms: written chunks go
* through update(), and the end pushes final(). */
export type CipherCCMTypes = "aes-128-ccm" | "aes-192-ccm" | "aes-256-ccm";
export type CipherGCMTypes = "aes-128-gcm" | "aes-192-gcm" | "aes-256-gcm";
export type CipherChaCha20Poly1305Types = "chacha20-poly1305";
export type CipherKey = BinaryLike | KeyObject;
export interface CipherCCMOptions {
authTagLength: number;
}
export interface CipherGCMOptions {
authTagLength?: number;
}
@@ -3197,6 +3229,10 @@ declare module "crypto" {
final(outputEncoding: BufferEncoding): string;
setAutoPadding(autoPadding?: boolean): this;
}
export interface CipherCCM extends Cipheriv {
setAAD(buffer: ArrayBufferView, options: { plaintextLength: number }): this;
getAuthTag(): Buffer;
}
export interface CipherGCM extends Cipheriv {
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
getAuthTag(): Buffer;
@@ -3218,6 +3254,10 @@ declare module "crypto" {
final(outputEncoding: BufferEncoding): string;
setAutoPadding(autoPadding?: boolean): this;
}
export interface DecipherCCM extends Decipheriv {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options: { plaintextLength: number }): this;
}
export interface DecipherGCM extends Decipheriv {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
@@ -3226,6 +3266,12 @@ declare module "crypto" {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
}
export function createCipheriv(
algorithm: CipherCCMTypes,
key: CipherKey,
iv: BinaryLike,
options: CipherCCMOptions,
): CipherCCM;
export function createCipheriv(
algorithm: CipherGCMTypes,
key: CipherKey,
@@ -3244,6 +3290,12 @@ declare module "crypto" {
iv: BinaryLike | null,
options?: { authTagLength?: number },
): Cipheriv;
export function createDecipheriv(
algorithm: CipherCCMTypes,
key: CipherKey,
iv: BinaryLike,
options: CipherCCMOptions,
): DecipherCCM;
export function createDecipheriv(
algorithm: CipherGCMTypes,
key: CipherKey,
@@ -3468,6 +3520,34 @@ declare module "crypto" {
* per member like http2.constants. */
export function getFips(): 1 | 0;
export function getCiphers(): string[];
export type CipherMode =
| "cbc"
| "ccm"
| "cfb"
| "ctr"
| "ecb"
| "gcm"
| "ocb"
| "ofb"
| "stream"
| "wrap"
| "xts";
export interface CipherInfoOptions {
keyLength?: number | undefined;
ivLength?: number | undefined;
}
export interface CipherInfo {
name: string;
nid: number;
blockSize?: number | undefined;
ivLength?: number | undefined;
keyLength: number;
mode: CipherMode;
}
export function getCipherInfo(
nameOrNid: string | number,
options?: CipherInfoOptions,
): CipherInfo | undefined;
export function getHashes(): string[];
export function getCurves(): string[];
/* Node v24 crypto.constants, literal-typed (the Http2Constants
@@ -5561,6 +5641,7 @@ declare module "http" {
readonly trailersDistinct: { [name: string]: string[] | undefined };
readonly rawTrailers: string[];
resume(): void;
pause(): void;
destroy(): void;
setTimeout(msecs: number, callback?: () => void): this;
/* setEncoding('utf8'): 'data' delivers strings (other real encodings
@@ -5986,6 +6067,7 @@ declare module "http2" {
export interface Http2Stream {
on(event: "error" | "close", listener: (err: Error) => void): void;
once(event: "error" | "close", listener: (err: Error) => void): void;
addListener(event: "error" | "close", listener: (err: Error) => void): void;
destroy(): void;
}
export interface Http2ServerRequest {
@@ -6086,11 +6168,6 @@ declare module "http2" {
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
on(event: "session", listener: (session: ServerHttp2Session) => void): void;
once(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
once(event: "session", listener: (session: ServerHttp2Session) => void): void;
/* HTTP CONNECT (and, in Node, the h2 extended CONNECT — which the
* allowHTTP1 lowering never sees): the second argument is the raw
* socket on HTTP/1.1 connections; narrow with instanceof. */
@@ -6108,6 +6185,42 @@ declare module "http2" {
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "close", listener: () => void): void;
once(event: "sessionError", listener: (err: Error, session: ServerHttp2Session) => void): void;
once(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
once(event: "session", listener: (session: ServerHttp2Session) => void): void;
once(
event: "connect",
listener: (
req: Http2ServerRequest | import("http").IncomingMessage,
resOrSocket: Http2ServerResponse | Socket,
) => void,
): void;
addListener(event: "connection", listener: (socket: Socket) => void): void;
addListener(
event: "request",
listener: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(event: "close", listener: () => void): void;
addListener(
event: "sessionError",
listener: (err: Error, session: ServerHttp2Session) => void,
): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "session", listener: (session: ServerHttp2Session) => void): void;
addListener(
event: "connect",
listener: (
req: Http2ServerRequest | import("http").IncomingMessage,
resOrSocket: Http2ServerResponse | Socket,
) => void,
): void;
}
export interface SecureServerOptions {
allowHTTP1?: boolean;
@@ -6449,6 +6562,28 @@ declare module "http2" {
listener: (errorCode: number, lastStreamID: number, opaqueData?: Uint8Array) => void,
): void;
once(event: "localSettings" | "remoteSettings", listener: (settings: any) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "ping", listener: (payload: Uint8Array) => void): void;
addListener(event: "close" | "timeout", listener: () => void): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "connect",
listener: (session: Http2Session, socket: import("net").Socket) => void,
): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "goaway",
listener: (errorCode: number, lastStreamID: number, opaqueData?: Uint8Array) => void,
): void;
addListener(event: "localSettings" | "remoteSettings", listener: (settings: any) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(event: "ping", listener: (payload: Uint8Array) => void): void;
}
export interface ClientHttp2Session extends Http2Session {
@@ -6526,7 +6661,30 @@ declare module "http2" {
listener: () => void,
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
addListener(event: "data", listener: (chunk: any) => void): void;
addListener(
event:
| "end"
| "close"
| "aborted"
| "ready"
| "timeout"
| "drain"
| "finish"
| "wantTrailers",
listener: () => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(
event: "trailers",
listener: (trailers: IncomingHttpHeaders, flags: number) => void,
): void;
respond(
headers?: OutgoingHttpHeaders,
options?: { endStream?: boolean; waitForTrailers?: boolean },
@@ -6576,6 +6734,7 @@ declare module "http2" {
on(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "response", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "push", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "headers", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "data", listener: (chunk: any) => void): void;
once(
event:
@@ -6590,6 +6749,42 @@ declare module "http2" {
listener: () => void,
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
addListener(
event: "response",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "push",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "headers",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "data", listener: (chunk: any) => void): void;
addListener(
event:
| "end"
| "close"
| "aborted"
| "ready"
| "timeout"
| "drain"
| "finish"
| "wantTrailers",
listener: () => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(
event: "trailers",
listener: (trailers: IncomingHttpHeaders, flags: number) => void,
): void;
}
export interface Http2Server {
@@ -6624,6 +6819,24 @@ declare module "http2" {
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "close" | "listening" | "timeout", listener: () => void): void;
once(event: "connection", listener: (socket: import("net").Socket) => void): void;
once(event: "sessionError", listener: (err: Error, session: ServerHttp2Session) => void): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "session", listener: (session: ServerHttp2Session) => void): void;
addListener(
event: "request",
listener: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(event: "close" | "listening" | "timeout", listener: () => void): void;
addListener(event: "connection", listener: (socket: import("net").Socket) => void): void;
addListener(
event: "sessionError",
listener: (err: Error, session: ServerHttp2Session) => void,
): void;
}
export interface ServerOptions {
Binary file not shown.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/compiler",
"version": "0.4.2",
"version": "0.4.3",
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
"tsc-ts": "0.0.8",
"tsc-ts": "0.0.9",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
@@ -0,0 +1,645 @@
// scriptc's built-in implementation of `@hono/node-server` 2.x: the Node.js
// adapter that serves a fetch-style handler (Hono's `app.fetch`) over
// node:http. The published package swaps the global Request and Response
// for lightweight objects and reparents prototypes, which only pays off
// under V8; this port builds native Request objects from the incoming
// message, with a body streamed from it as the app reads it, and writes
// the handler's Response the way the package's listener does (header
// record, content length for bodies that complete within the first reads,
// streamed bodies, error responses), so a client sees the same status
// lines, headers and bodies. A body the app leaves unread is drained after
// the response, within the package's limits.
//
// HTTP/1.1 only: the http2/https server options and WebSocket upgrades are
// not declared. A Response that is still exactly its constructor arguments
// takes the package's cached-response path (its lightweight Response), read
// from the runtime through scriptcResponseLight; every other Response takes
// the general path.
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export type HttpBindings = {
incoming: IncomingMessage;
outgoing: ServerResponse;
};
export type ServerType = Server;
export type FetchCallback = (request: Request, env: HttpBindings) => Response | Promise<Response>;
export type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>;
export type Options = {
fetch: FetchCallback;
overrideGlobalObjects?: boolean;
autoCleanupIncoming?: boolean;
port?: number;
hostname?: string;
};
export interface RequestListenerOptions {
hostname?: string;
errorHandler?: CustomErrorHandler;
overrideGlobalObjects?: boolean;
autoCleanupIncoming?: boolean;
}
export class RequestError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "RequestError";
}
}
const DEFAULT_CONTENT_TYPE = "text/plain; charset=UTF-8";
const X_ALREADY_SENT = "x-hono-already-sent";
const reValidRequestUrl = /^\/[!#$&-;=?-[\]_a-z~]*$/;
const reDotSegment = /\/\.\.?(?:[/?#]|$)/;
const reValidHost = /^[a-z0-9._-]+(?::(?:[1-5]\d{3,4}|[6-9]\d{3}))?$/;
function buildUrl(scheme: string, host: string, incomingUrl: string): string {
const url = `${scheme}://${host}${incomingUrl}`;
if (!reValidHost.test(host)) {
const urlObj = new URL(url);
if (
urlObj.hostname.length !== host.length &&
urlObj.hostname !== (host.includes(":") ? host.replace(/:\d+$/, "") : host).toLowerCase()
)
throw new RequestError("Invalid host header");
return urlObj.href;
}
if (incomingUrl.length === 0) return url + "/";
if (incomingUrl.charCodeAt(0) !== 47) throw new RequestError("Invalid URL");
if (!reValidRequestUrl.test(incomingUrl) || reDotSegment.test(incomingUrl))
return new URL(url).href;
return url;
}
function normalizeIncomingMethod(method: string | undefined): string {
if (method === undefined || method.length === 0) return "GET";
switch (method) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "PATCH":
case "POST":
case "PUT":
case "QUERY":
return method;
}
const upper = method.toUpperCase();
switch (upper) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "POST":
case "PUT":
return upper;
default:
return method;
}
}
/** The request URL the package builds: an absolute request target as is,
* otherwise the Host header (or the configured hostname) over http. */
function requestUrl(incoming: IncomingMessage, defaultHostname: string | undefined): string {
const incomingUrl = incoming.url ?? "";
if (
incomingUrl[0] !== "/" &&
(incomingUrl.startsWith("http://") || incomingUrl.startsWith("https://"))
) {
try {
return new URL(incomingUrl).href;
} catch (e) {
throw new RequestError("Invalid absolute URL", { cause: e });
}
}
const hostHeader = incoming.headers.host;
const host = hostHeader !== undefined && hostHeader !== "" ? hostHeader : (defaultHostname ?? "");
if (host === "") throw new RequestError("Missing host header");
try {
return buildUrl("http", host, incomingUrl);
} catch (e) {
if (e instanceof RequestError) throw e;
throw new RequestError("Invalid URL", { cause: e });
}
}
function requestHeaders(incoming: IncomingMessage): Headers {
const headers = new Headers();
const raw = incoming.rawHeaders;
for (let i = 0; i < raw.length; i += 2) {
const name = raw[i]!;
if (!name.startsWith(":")) headers.append(name, raw[i + 1]!);
}
return headers;
}
/** What the listener knows about the incoming body: whether it ended
* (the package's `readableEnded`) and whether a drain started. */
type IncomingState = { ended: boolean; draining: boolean };
/** The incoming message's readableHighWaterMark: Readable.toWeb queues
* this many bytes before it pauses the message. */
const BODY_HIGH_WATER_MARK = 64 * 1024;
/** The request body as the package streams it: Readable.toWeb over the
* incoming message, read one chunk per pull. Chunks queue until
* BODY_HIGH_WATER_MARK bytes wait unread, then the message pauses until a
* pull takes them, so the socket holds the rest. The stream pulls once on
* its own when it is constructed; the package builds its stream only when
* the app reads the body, so until the app's first read a drain discards
* the chunks instead of queueing them. */
function incomingBody(incoming: IncomingMessage, state: IncomingState): ReadableStream<Uint8Array> {
let pulls = 0;
let listening = false;
let ended = false;
let failure: Error | undefined = undefined;
const queue: Buffer[] = [];
let queued = 0;
let wake: (() => void) | undefined = undefined;
const woken = (): void => {
const resolve = wake;
wake = undefined;
if (resolve !== undefined) resolve();
};
const listen = (): void => {
listening = true;
incoming.on("data", (chunk: Buffer) => {
if (state.draining && pulls === 1) return;
queue.push(chunk);
queued += chunk.length;
if (queued >= BODY_HIGH_WATER_MARK) incoming.pause();
woken();
});
incoming.on("end", () => {
ended = true;
woken();
});
incoming.on("error", (err: Error) => {
failure = err;
woken();
});
};
return new ReadableStream<Uint8Array>({
async pull(controller) {
pulls++;
if (!listening) listen();
for (;;) {
const chunk = queue.shift();
if (chunk !== undefined) {
queued -= chunk.length;
if (queued < BODY_HIGH_WATER_MARK) incoming.resume();
controller.enqueue(chunk);
return;
}
if (failure !== undefined) {
controller.error(failure);
return;
}
if (ended) {
controller.close();
return;
}
await new Promise<void>((resolve) => {
wake = resolve;
});
}
},
});
}
function newRequest(
incoming: IncomingMessage,
defaultHostname: string | undefined,
state: IncomingState,
): Request {
const method = normalizeIncomingMethod(incoming.method);
const url = requestUrl(incoming, defaultHostname);
const headers = requestHeaders(incoming);
if (method === "GET" || method === "HEAD") return new Request(url, { method, headers });
return new Request(url, {
method,
headers,
body: incomingBody(incoming, state),
duplex: "half",
});
}
const DRAIN_TIMEOUT_MS = 500;
const MAX_DRAIN_BYTES = 64 * 1024 * 1024;
/** The package's drain of a body the app left unread: read and discard
* up to MAX_DRAIN_BYTES or DRAIN_TIMEOUT_MS, then close the connection
* once its writes are out. */
function drainIncoming(incoming: IncomingMessage, state: IncomingState): void {
const socket = incoming.socket;
if (socket.destroyed || state.draining) return;
state.draining = true;
let bytesRead = 0;
let done = false;
const cleanup = (): void => {
if (done) return;
done = true;
clearTimeout(timer);
};
const forceClose = (): void => {
if (done) return;
cleanup();
if (!socket.destroyed) socket.destroySoon();
};
const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS);
timer.unref();
incoming.on("data", (chunk: Buffer) => {
if (done) return;
bytesRead += chunk.length;
if (bytesRead > MAX_DRAIN_BYTES) forceClose();
});
incoming.on("end", cleanup);
incoming.on("error", cleanup);
incoming.resume();
}
/** The package's close handler cleanup: an unread body drains two timer
* turns after the response closes. */
function drainAfterClose(incoming: IncomingMessage, state: IncomingState): void {
if (state.ended) return;
setTimeout(() => {
if (!state.ended)
setTimeout(() => {
drainIncoming(incoming, state);
});
});
}
/** The package's outgoing header record: Headers order (lowercase,
* sorted), Set-Cookie values gathered into one array entry at the end,
* and the default content type for a response with a body. */
function buildOutgoingHttpHeaders(
headers: Headers,
hasBody: boolean,
): Record<string, string | string[]> {
const res: Record<string, string | string[]> = {};
if (headers.has("set-cookie")) {
const cookies: string[] = [];
headers.forEach((v, k) => {
if (k === "set-cookie") cookies.push(v);
else res[k] = v;
});
// Headers.forEach joins nothing but set-cookie, which getSetCookie
// lists value by value, as the package's iteration does.
if (cookies.length > 0) res["set-cookie"] = headers.getSetCookie();
} else {
headers.forEach((v, k) => {
res[k] = v;
});
}
if (hasBody && res["content-type"] === undefined) res["content-type"] = DEFAULT_CONTENT_TYPE;
return res;
}
/* Spelled out rather than ReadableStreamReadResult<Uint8Array>, whose
* declaration depends on the program's lib and @types/node settings: the
* shim compiles inside every program that imports the package. */
type ReadResult = { done: boolean; value?: Uint8Array };
function readWithoutBlocking(read: Promise<ReadResult>): Promise<ReadResult | undefined> {
return Promise.race([read, Promise.resolve().then(() => Promise.resolve(undefined))]);
}
function nextTick(): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
/** Writes the rest of a body stream. As in the package, a response that
* closes before the stream ends (the client disconnected) cancels the
* stream, so its producer stops; the pending read then answers done. A
* response that fails closes too, and its stream is cancelled the same way
* (the package passes the error as the reason). The listener stays attached
* and does nothing once the stream has ended. */
async function pump(
reader: ReadableStreamDefaultReader<Uint8Array>,
outgoing: ServerResponse,
pending: Promise<ReadResult> | undefined,
): Promise<void> {
let finished = false;
outgoing.on("close", () => {
if (!finished) reader.cancel().catch(() => {});
});
try {
let read = pending ?? reader.read();
for (;;) {
const { done, value } = await read;
if (done) {
outgoing.end();
return;
}
outgoing.write(value!);
read = reader.read();
}
} finally {
finished = true;
}
}
function handleResponseError(e: unknown, outgoing: ServerResponse): void {
const err = e instanceof Error ? e : new Error("unknown error", { cause: e });
console.error(e);
if (!outgoing.headersSent) outgoing.writeHead(500, { "Content-Type": "text/plain" });
outgoing.end(`Error: ${err.message}`);
outgoing.destroy();
}
function emptyResponse(status: number): Response {
return new Response(null, { status });
}
/** The lightweight state of a constructed Response (the runtime's
* scr_fetch_response_light): [form, flags, byte length, ...record pairs]. */
declare function scriptcResponseLight(res: Response): string[];
const UNDICI_TEXT_TYPE = "text/plain;charset=UTF-8";
/** Content-Length as Node's implicit header: set the headers one by one and
* let end(body) compute the length, which Node then writes after its own
* Date/Connection headers, as the package's preset length does. */
function endWithImplicitLength(
outgoing: ServerResponse,
status: number,
header: Record<string, string | string[]>,
body: Uint8Array,
): void {
outgoing.statusCode = status;
for (const name of Object.keys(header)) outgoing.setHeader(name, header[name]!);
outgoing.end(body);
}
/** The package's cached-response path: a Response that is still exactly
* its constructor arguments (no body or metadata read since) is written
* from them. Form 1 has no init headers, form 2 Headers, form 3 a plain
* record kept verbatim. */
async function writeLightResponse(
res: Response,
outgoing: ServerResponse,
light: string[],
head: boolean,
): Promise<void> {
const form = light[0]!;
const flags = light[1]!;
const length = Number(light[2]!);
const status = res.status;
const sized = length >= 0;
if (form === "1") {
if (length === -1) {
outgoing.writeHead(status);
outgoing.end();
} else if (sized) {
const body = await res.bytes();
if (head) {
outgoing.writeHead(status, {
"Content-Type": DEFAULT_CONTENT_TYPE,
"Content-Length": String(length),
});
outgoing.end(body);
} else
endWithImplicitLength(outgoing, status, { "Content-Type": DEFAULT_CONTENT_TYPE }, body);
} else {
outgoing.writeHead(status, { "Content-Type": DEFAULT_CONTENT_TYPE });
outgoing.flushHeaders();
await pump(res.body!.getReader(), outgoing, undefined);
}
return;
}
let header: Record<string, string | string[]>;
let hasContentLength = false;
let plain = false;
let canAutoLength = true;
if (form === "2") {
const headers = new Headers(res.headers);
if (flags.includes("a") && headers.get("content-type") === UNDICI_TEXT_TYPE) {
if (flags.includes("d")) headers.set("content-type", DEFAULT_CONTENT_TYPE);
else headers.delete("content-type");
} else if (flags.includes("d") && !headers.has("content-type")) {
headers.set("content-type", DEFAULT_CONTENT_TYPE);
}
hasContentLength = headers.has("content-length");
header = buildOutgoingHttpHeaders(headers, length !== -1);
} else {
plain = true;
header = {};
for (let i = 3; i + 1 < light.length; i += 2) {
const name = light[i]!;
const value = light[i + 1]!;
const previous = header[name];
if (previous === undefined) header[name] = value;
else if (typeof previous === "string") header[name] = [previous, value];
else previous.push(value);
const lower = name.toLowerCase();
if (lower === "content-length") hasContentLength = true;
else if (lower === "transfer-encoding" || lower === "trailer") canAutoLength = false;
}
}
const body = sized ? await res.bytes() : undefined;
if (!hasContentLength && body !== undefined) {
if (plain && canAutoLength && !head && status !== 204 && status !== 304) {
endWithImplicitLength(outgoing, status, header, body);
return;
}
header["Content-Length"] = String(length);
}
outgoing.writeHead(status, header);
if (length === -1) outgoing.end();
else if (body !== undefined) outgoing.end(body);
else {
outgoing.flushHeaders();
await pump(res.body!.getReader(), outgoing, undefined);
}
}
async function writeResponse(
res: Response,
outgoing: ServerResponse,
head: boolean,
): Promise<void> {
const light = scriptcResponseLight(res);
if (light[0] !== "0") return writeLightResponse(res, outgoing, light, head);
const body = res.body;
const record = buildOutgoingHttpHeaders(res.headers, body !== null);
if (body !== null) {
const reader: ReadableStreamDefaultReader<Uint8Array> = body.getReader();
const values: Uint8Array[] = [];
let done = false;
let pending: Promise<ReadResult> | undefined = undefined;
if (record["transfer-encoding"] !== "chunked") {
let maxReadCount = 2;
for (let i = 0; i < maxReadCount; i++) {
pending ??= reader.read();
let chunk: ReadResult | undefined = undefined;
try {
chunk = await readWithoutBlocking(pending);
} catch (e) {
console.error(e);
done = true;
}
if (chunk === undefined) {
if (i === 1 && !done) {
await nextTick();
maxReadCount = 3;
continue;
}
break;
}
pending = undefined;
if (chunk.value !== undefined) values.push(chunk.value);
if (chunk.done) {
done = true;
break;
}
}
if (done && record["content-length"] === undefined) {
let length = 0;
for (const value of values) length += value.length;
record["content-length"] = String(length);
}
}
outgoing.writeHead(res.status, record);
for (const value of values) outgoing.write(value);
if (done) outgoing.end();
else {
if (values.length === 0) outgoing.flushHeaders();
await pump(reader, outgoing, pending);
}
} else if (record[X_ALREADY_SENT] === undefined) {
outgoing.writeHead(res.status, record);
outgoing.end();
}
}
async function respond(
pending: Response | Promise<Response>,
outgoing: ServerResponse,
errorHandler: CustomErrorHandler | undefined,
head: boolean,
): Promise<void> {
let res: Response;
if (pending instanceof Promise) {
try {
res = await pending;
} catch (err) {
if (errorHandler === undefined) {
res = handleFetchError(err);
} else {
const errRes = await errorHandler(err);
if (errRes === undefined) return;
res = errRes;
}
}
} else res = pending;
await writeResponse(res, outgoing, head);
}
/** A Response the package writes from its cache without waiting: still
* exactly its constructor arguments, with no body or a string or bytes
* body. */
function immediateCacheable(res: Response): boolean {
const light = scriptcResponseLight(res);
return light[0] !== "0" && Number(light[2]!) >= -1;
}
function handleFetchError(e: unknown): Response {
return emptyResponse(e instanceof Error && e.name === "TimeoutError" ? 504 : 500);
}
async function handleRequest(
fetchCallback: FetchCallback,
options: RequestListenerOptions,
incoming: IncomingMessage,
outgoing: ServerResponse,
): Promise<void> {
let req: Request | undefined = undefined;
let res: Response | Promise<Response> | undefined = undefined;
const state: IncomingState = { ended: false, draining: false };
let needsBodyCleanup = false;
let closeHandlerAttached = false;
const ensureCloseHandler = (): void => {
if (req === undefined || closeHandlerAttached) return;
closeHandlerAttached = true;
if (needsBodyCleanup)
outgoing.on("close", () => {
drainAfterClose(incoming, state);
});
};
try {
req = newRequest(incoming, options.hostname, state);
needsBodyCleanup =
options.autoCleanupIncoming !== false &&
!(incoming.method === "GET" || incoming.method === "HEAD");
if (needsBodyCleanup)
incoming.on("end", () => {
state.ended = true;
});
res = fetchCallback(req, { incoming, outgoing });
if (!(res instanceof Promise) && immediateCacheable(res)) {
if (needsBodyCleanup)
outgoing.once("finish", () => {
if (!state.ended) drainIncoming(incoming, state);
});
} else ensureCloseHandler();
} catch (e) {
if (res !== undefined) return handleResponseError(e, outgoing);
const errorHandler = options.errorHandler;
if (errorHandler !== undefined) {
ensureCloseHandler();
const handled = await errorHandler(
req !== undefined ? e : e instanceof RequestError ? e : toRequestError(e),
);
if (handled === undefined) return;
res = handled;
} else if (req === undefined) res = emptyResponse(400);
else res = handleFetchError(e);
}
try {
await respond(res, outgoing, options.errorHandler, incoming.method === "HEAD");
} catch (e) {
handleResponseError(e, outgoing);
}
}
function toRequestError(e: unknown): RequestError {
return new RequestError(e instanceof Error ? e.message : String(e), { cause: e });
}
export function getRequestListener(
fetchCallback: FetchCallback,
options: RequestListenerOptions = {},
): (incoming: IncomingMessage, outgoing: ServerResponse) => Promise<void> {
return (incoming, outgoing) => handleRequest(fetchCallback, options, incoming, outgoing);
}
export function createAdaptorServer(options: Options): ServerType {
const listener = getRequestListener(options.fetch, {
hostname: options.hostname,
overrideGlobalObjects: options.overrideGlobalObjects,
autoCleanupIncoming: options.autoCleanupIncoming,
});
return createServer((incoming, outgoing) => {
void listener(incoming, outgoing);
});
}
export function serve(
options: Options,
listeningListener?: (info: AddressInfo) => void,
): ServerType {
const server = createAdaptorServer(options);
server.listen(options.port ?? 3000, options.hostname, () => {
const info = server.address() as AddressInfo;
if (listeningListener !== undefined) listeningListener(info);
});
return server;
}
@@ -50,6 +50,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
+26 -24
View File
@@ -3,6 +3,7 @@
import type { DynamicFieldStores } from "../ir/int32-slots.js";
import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js";
import {
computeCycleMutability,
constructorEscapePoint,
@@ -97,7 +98,10 @@ export interface ConstructionFacts {
privateStores: Set<IrStmt>;
}
export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
/** The nodes that can reach a constructor with a reused receiver. */
const CONSTRUCTOR_USE_KINDS: readonly IrNodeKind[] = ["call", "closure"];
export function computeConstructionFacts(mod: IrModule, nodes?: IrNodeIndex): ConstructionFacts {
const facts: ConstructionFacts = { privateStores: new Set() };
if (mod.lib !== undefined) return facts;
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const));
@@ -122,6 +126,7 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
c = classes.get(c)!.base;
}
};
const scan = nodes?.scan(CONSTRUCTOR_USE_KINDS);
for (const fn of mod.functions) {
const own = ctorClass.get(fn.name);
const thisId = own !== undefined ? thisOf(own) : null;
@@ -129,22 +134,19 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
own !== undefined && classes.get(own)!.base !== undefined
? `%${classes.get(own)!.base}.constructor`
: null;
const expr = (e: IrExpr): boolean => {
if (e.kind === "call") {
const target = ctorClass.get(e.callee);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") {
const target = ctorClass.get(node.callee);
if (
target !== undefined &&
!(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
!(node.callee === baseCtor && node.args.length > 0 && isThisRef(node.args[0]!, thisId))
)
markReused(target);
} else if (e.kind === "closure") {
const target = ctorClass.get(e.fnName);
} else if (node.kind === "closure") {
const target = ctorClass.get(node.fnName);
if (target !== undefined) markReused(target);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt);
fn.body.every(stmt);
});
}
// The first escaping statement of each constructor (body length: none).
@@ -192,32 +194,32 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
return facts;
}
/** The nodes that write a binding: statement and expression assignments. */
const ASSIGNMENT_KINDS: readonly IrNodeKind[] = ["assign", "assignExpr"];
/** Module constants whose value never changes once initialized: immutable,
* not TDZ-checked (every read follows the initialization), no import-cycle
* init flag, and at most one assignment in the whole module. Such a global
* owns its value for the rest of the program, so a read of it can be
* borrowed across any later operand or call. */
export function stableGlobals(mod: IrModule): Set<string> {
export function stableGlobals(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const candidates = new Set(
(mod.globals ?? [])
.filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
.map((g) => g.id),
);
const assigned = new Set<string>();
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "assign" && candidates.has(s.localId)) {
if (assigned.has(s.localId)) candidates.delete(s.localId);
assigned.add(s.localId);
}
return everyStmtChild(s, expr, stmt);
};
const scan = nodes?.scan(ASSIGNMENT_KINDS);
for (const fn of mod.functions) {
if (candidates.size === 0) break;
fn.body.every(stmt);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "assignExpr" && candidates.has(node.localId))
candidates.delete(node.localId);
else if (node.kind === "assign" && candidates.has(node.localId)) {
if (assigned.has(node.localId)) candidates.delete(node.localId);
assigned.add(node.localId);
}
});
}
return candidates;
}
@@ -54,6 +54,7 @@ export async function compilePackedLibrary(
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
+65 -12
View File
@@ -459,18 +459,72 @@ export type FreeObservers = "weak" | "weak-or-source";
* type's disposals), and the block — `scr_cyc_free` for collector-headered
* objects, `scr_rt_free` otherwise. Labels use the `sf.` prefix; the lines
* end in an open block.
*
* With `keep` (an alloca of ptr holding NULL; inline allocation paths only),
* a block that would go onto the calling thread's free list is stored there
* instead, for a teardown segment (emitSegmentFree); a block the system or
* another arena owns is still freed.
*/
export function emitObjectFree(
host: AllocHost,
cyc: boolean,
observers: FreeObservers = "weak",
keep: string | null = null,
): string[] {
const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers));
const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers, keep));
if (host.objectAudit === false) return lines.filter((line) => line !== FREE_NOTE);
host.declare(`declare void @scr_obj_free_note()`);
return lines;
}
/** Lines that put the blocks `first` to `last` (linked through their first
* words; one block when they are equal) of the class at reservation offset
* `off` on the calling thread's free list. */
function freeListPushLines(prefix: string, off: string, first: string, last: string): string[] {
return [
` %${prefix}.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
` %${prefix}.sh32 = load i32, ptr %${prefix}.shp`,
` %${prefix}.sh = zext i32 %${prefix}.sh32 to i64`,
` %${prefix}.c = lshr i64 ${off}, %${prefix}.sh`,
` %${prefix}.c8 = shl i64 %${prefix}.c, 3`,
` %${prefix}.fo = add i64 %${prefix}.c8, ${SA_FREE}`,
` %${prefix}.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %${prefix}.fo`,
` %${prefix}.head = load ptr, ptr %${prefix}.fp`,
` store ptr %${prefix}.head, ptr ${last}`,
` store ptr ${first}, ptr %${prefix}.fp`,
];
}
/**
* Lines that free a teardown segment: the blocks `first` to `last` (ptr
* values, both NULL for an empty segment), linked through their first words,
* which emitObjectFree kept. Every block of a segment is in the calling
* thread's arena and of one size class, so the segment goes onto that
* class's free list in one step, and the next allocations of the class take
* its blocks in segment order. Labels use `prefix`; the lines end in the
* open block `${prefix}.done`.
*/
export function emitSegmentFree(
host: AllocHost,
prefix: string,
first: string,
last: string,
): string[] {
declareState(host);
const lines = [
` %${prefix}.none = icmp eq ptr ${first}, null`,
` br i1 %${prefix}.none, label %${prefix}.done, label %${prefix}.push`,
`${prefix}.push:`,
` %${prefix}.base = load i64, ptr @scr_sa`,
` %${prefix}.bi = ptrtoint ptr ${first} to i64`,
` %${prefix}.off = sub i64 %${prefix}.bi, %${prefix}.base`,
...freeListPushLines(prefix, `%${prefix}.off`, first, last),
` br label %${prefix}.done`,
`${prefix}.done:`,
];
return threadLocalState(host, prefix, lines);
}
/** Lines that leave in `%<prefix>.obs` whether the program can observe a
* disposal of this type (folds to a constant). */
function observedLines(prefix: string, observers: FreeObservers): string[] {
@@ -482,8 +536,14 @@ function observedLines(prefix: string, observers: FreeObservers): string[] {
];
}
function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): string[] {
function freeLines(
host: AllocHost,
cyc: boolean,
observers: FreeObservers,
keep: string | null,
): string[] {
if (host.inlineAlloc !== true) {
if (keep !== null) throw new Error("a kept block needs the inline allocation paths");
if (cyc) {
host.declare(`declare void @scr_cyc_free(ptr)`);
return [` call void @scr_obj_free_note()`, ` call void @scr_cyc_free(ptr %o)`];
@@ -548,16 +608,9 @@ function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): str
` %sf.inx = call i1 @llvm.expect.i1(i1 %sf.in, i1 true)`,
` br i1 %sf.inx, label %sf.push, label %sf.sys`,
`sf.push:`,
` %sf.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
` %sf.sh32 = load i32, ptr %sf.shp`,
` %sf.sh = zext i32 %sf.sh32 to i64`,
` %sf.c = lshr i64 %sf.off, %sf.sh`,
` %sf.c8 = shl i64 %sf.c, 3`,
` %sf.fo = add i64 %sf.c8, ${SA_FREE}`,
` %sf.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %sf.fo`,
` %sf.head = load ptr, ptr %sf.fp`,
` store ptr %sf.head, ptr ${blk}`,
` store ptr ${blk}, ptr %sf.fp`,
...(keep !== null
? [` store ptr ${blk}, ptr ${keep}`]
: freeListPushLines("sf", "%sf.off", blk, blk)),
` br label %sf.done`,
`sf.sys: ; outside the reservation, or the allocator is compiled out`,
` call void @scr_obj_free_note()`,
@@ -9,8 +9,8 @@
* one directly or through a binding read from it. The choice never changes
* behavior. */
import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { IrExpr, IrModule } from "../../ir/ir.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const APPENDING_METHODS: ReadonlySet<string> = new Set([
"push",
@@ -30,7 +30,27 @@ function isEmptyLiteral(e: IrExpr): boolean {
);
}
export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr> {
/** The nodes that name an appended binding or field, and the stores that
* initialize one. */
const TARGET_KINDS: readonly IrNodeKind[] = [
"arrIntrinsic",
"assignExpr",
"arraySet",
"varDecl",
"assign",
];
const STORE_KINDS: readonly IrNodeKind[] = [
"assignExpr",
"varDecl",
"assign",
"fieldSet",
"recordSet",
];
export function findAppendedArrayLiterals(
module: IrModule,
index?: IrNodeIndex,
): ReadonlySet<IrExpr> {
// Bindings are function-scoped by id; module globals are shared.
const localsOf = new Map<string, ReadonlySet<string>>();
for (const fn of module.functions) {
@@ -65,20 +85,15 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr>
const field = fieldOf(target);
if (field !== null) appendedFields.add(field);
};
const targets = index?.scan(TARGET_KINDS);
for (const fn of module.functions) {
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "arrIntrinsic" && APPENDING_METHODS.has(e.method))
noteTarget(fn.name, e.receiver);
else if (e.kind === "assignExpr") noteAlias(fn.name, e.localId, e.value);
return true;
},
stmt: (s) => {
if (s.kind === "arraySet") noteTarget(fn.name, s.arr);
else if (s.kind === "varDecl") noteAlias(fn.name, s.localId, s.init);
else if (s.kind === "assign") noteAlias(fn.name, s.localId, s.value);
return true;
},
eachNodeOfKinds(fn, targets, (node) => {
if (node.kind === "arrIntrinsic" && APPENDING_METHODS.has(node.method))
noteTarget(fn.name, node.receiver);
else if (node.kind === "assignExpr") noteAlias(fn.name, node.localId, node.value);
else if (node.kind === "arraySet") noteTarget(fn.name, node.arr);
else if (node.kind === "varDecl") noteAlias(fn.name, node.localId, node.init);
else if (node.kind === "assign") noteAlias(fn.name, node.localId, node.value);
});
}
for (const binding of appendedBindings)
@@ -89,23 +104,18 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr>
const consider = (value: IrExpr | null, appended: boolean): void => {
if (value && appended && isEmptyLiteral(value)) result.add(value);
};
const stores = index?.scan(STORE_KINDS);
for (const fn of module.functions) {
const binding = (localId: string): boolean =>
appendedBindings.has(bindingKey(fn.name, localId));
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "assignExpr") consider(e.value, binding(e.localId));
return true;
},
stmt: (s: IrStmt) => {
if (s.kind === "varDecl") consider(s.init, binding(s.localId));
else if (s.kind === "assign") consider(s.value, binding(s.localId));
else if (s.kind === "fieldSet")
consider(s.value, appendedFields.has(fieldKey(s.className, s.field)));
else if (s.kind === "recordSet")
consider(s.value, appendedFields.has(fieldKey(s.shapeId, s.field)));
return true;
},
eachNodeOfKinds(fn, stores, (node) => {
if (node.kind === "assignExpr") consider(node.value, binding(node.localId));
else if (node.kind === "varDecl") consider(node.init, binding(node.localId));
else if (node.kind === "assign") consider(node.value, binding(node.localId));
else if (node.kind === "fieldSet")
consider(node.value, appendedFields.has(fieldKey(node.className, node.field)));
else if (node.kind === "recordSet")
consider(node.value, appendedFields.has(fieldKey(node.shapeId, node.field)));
});
}
return result;
@@ -7,9 +7,15 @@ import type { LlvmEmitterContext } from "./expr-context.js";
* buffer. Neither overlaps the other, a native field, array storage or a
* module global's slot, so element stores leave header loads and global
* receiver slots loop-invariant. Untagged accesses and calls still alias
* every category. */
* every category.
*
* Header loads are also `!invariant.load`: no store or call between the
* construction of a view and its release changes them. Without it, a data
* pointer load ahead of a bounds branch is sunk into the in-bounds block,
* and LICM keeps a conditionally executed load inside the loop. */
export function bytesHeaderAlias(host: LlvmEmitterContext): string {
return host.memoryAliasAttachment("bytes:header");
const tag = host.memoryAliasAttachment("bytes:header");
return tag === "" ? "" : `${tag}, !invariant.load !{}`;
}
export function bytesElementAlias(host: LlvmEmitterContext): string {
@@ -11,7 +11,7 @@ import {
type IrType,
} from "../../ir/ir.js";
import { analyzeIntegerRanges } from "../../ir/integer-ranges.js";
import { matchIntegerCountedForLoop } from "../../ir/integer-loops.js";
import { matchIntegerCountedForLoop, matchIntegerPairedForLoop } from "../../ir/integer-loops.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion, type BytesLoopVersion } from "./bytes-loop-versions.js";
import { emitLlvmModule } from "./emitter.js";
@@ -249,6 +249,11 @@ test("emitted loops version the body and tag typed-array memory", () => {
const elements = /store double [^\n]*!tbaa !(\d+)/.exec(body)![1];
const header = /load ptr, ptr [^\n]*!tbaa !(\d+)/.exec(body)![1];
expect(elements).not.toBe(header);
// Header loads are invariant for the view's lifetime; element accesses
// are not.
expect(body).toMatch(/load ptr, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
expect(body).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
expect(body).not.toMatch(/(load double|store double)[^\n]*invariant\.load/);
});
test("loops to a typed array's length version the other typed arrays they index", () => {
@@ -618,3 +623,78 @@ test("while loop counters below a string's length version its reads", () => {
expect(version.bounded.map((c) => c.slack)).toEqual([0]);
expect(version.fixed).toEqual([]);
});
test("a two-counter loop versions the typed arrays it indexes at either counter", () => {
// for (let i = 0, j = k; i < j; i++, j--) { const t = out[i]; out[i] = out[j]; out[j] = t; }
const loop: IrStmt & { kind: "for" } = {
kind: "for",
init: {
kind: "block",
body: [
{ kind: "varDecl", localId: "i", init: num(0), loc },
{ kind: "varDecl", localId: "j", init: ref("k"), loc },
],
loc,
},
cond: { kind: "bin", op: "<", left: ref("i"), right: ref("j"), type: BOOL, loc },
update: {
kind: "block",
body: [
{ kind: "assign", localId: "i", value: bin("+", ref("i"), num(1)), loc },
{ kind: "assign", localId: "j", value: bin("-", ref("j"), num(1)), loc },
],
loc,
},
body: [
{ kind: "varDecl", localId: "t", init: get(ref("out", f64s), ref("i")), loc },
set(ref("out", f64s), ref("i"), get(ref("out", f64s), ref("j"))),
set(ref("out", f64s), ref("j"), ref("t")),
// Another index stays checked.
set(ref("src", f64s), bin("+", ref("i"), num(1)), num(0)),
],
loc,
};
const kernel = fn([loop], [local("t", F64)]);
const ranges = analyzeIntegerRanges(kernel);
const locals = new Map(kernel.locals.map((l) => [l.id, l]));
const paired = matchIntegerPairedForLoop(loop, locals, ranges)!;
expect(paired).not.toBeNull();
const version = matchBytesLoopVersion(
loop,
null,
{
locals,
invariantGlobals: new Set(),
captured: new Set(),
ranges,
initialized: findInitializedByteLoopBindings(kernel).get(loop) ?? new Set(),
proven: new Set(),
workers: false,
owned: new Set(),
scalarStrings: new Set(),
},
paired,
)!;
expect(version.paired?.loop).toBe(paired);
expect(version.paired?.receivers.map((r) => version.receivers[r]!.ref)).toEqual([
ref("out", f64s),
]);
expect(version.bounds.size).toBe(4);
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "__main",
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
kernel,
],
};
const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m));
expect(guard).toMatch(/icmp sge i64 %\S+, 0/);
expect(guard).toMatch(/icmp slt i64 %\S+, %\S+/);
const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0];
// The fast copy keeps only the `src[i + 1]` check, and no second guard.
expect(fast.match(/bytes\.index\.invalid\d+:/g)).toHaveLength(1);
expect(fast).not.toContain("counted.fast");
});
@@ -1,11 +1,15 @@
import { F64, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import type { IntegerRange, IntegerRanges } from "../../ir/integer-ranges.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js";
import {
emitCountedLoopLimit,
emitCountedLoopSafety,
emitExactIntegerTest,
} from "./counted-loops.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import { f64Lit } from "./common.js";
import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js";
@@ -79,6 +83,10 @@ export interface BytesLoopVersion {
guarded: ReadonlySet<string>;
/** The counted loop whose iteration space the affine checks cover. */
counted: IntegerCountedForLoop | null;
/** A two-counter loop (`for (let i = lo, j = hi; i < j; i++, j--)`) and
* the receivers it indexes at either counter. Both counters stay between
* the two starts, so the guard requires `lo >= 0` and `hi < length`. */
paired: { loop: IntegerPairedForLoop; receivers: number[] } | null;
/** The guard also requires every counter value within [-bound, bound]. */
counterBound: number | null;
/** Integer ranges of affine index operands that hold in the unchecked
@@ -153,6 +161,7 @@ export function matchBytesLoopVersion(
loop: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
facts: BytesLoopFacts,
paired: IntegerPairedForLoop | null = null,
): BytesLoopVersion | null {
const written = new Set<string>();
const temps = new Map<string, IrExpr>();
@@ -438,6 +447,9 @@ export function matchBytesLoopVersion(
string,
{ check: BoundedCheck; nodes: { node: Node; access: WhileAccess }[] }
>();
const pairedCounters =
paired !== null && counted === null ? new Set([paired.low.localId, paired.high.localId]) : null;
const pairedNodes = new Map<number, Node[]>();
const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => {
const ref = root(array);
if (!ref) return;
@@ -448,6 +460,20 @@ export function matchBytesLoopVersion(
else owned.set(receiver, [node]);
}
if (facts.proven.has(node)) return;
// An element at either counter of a two-counter loop.
if (
pairedCounters !== null &&
width === 1 &&
ref.type.kind === "bytes" &&
index.kind === "varRef" &&
pairedCounters.has(index.localId)
) {
const receiver = receiverOf(ref);
const nodes = pairedNodes.get(receiver);
if (nodes) nodes.push(node);
else pairedNodes.set(receiver, [node]);
return;
}
const shape = counter !== null ? affine(index) : null;
if (shape && shape.counter !== 0) {
const operands = new Set<IrExpr>();
@@ -543,11 +569,16 @@ export function matchBytesLoopVersion(
Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length),
);
const ownedKept = [...owned.entries()].slice(0, MAX_CHECKS);
const pairedKept = [...pairedNodes.entries()].slice(
0,
Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length - boundedKept.length),
);
if (
fixedKept.length === 0 &&
affineKept.length === 0 &&
boundedKept.length === 0 &&
ownedKept.length === 0
ownedKept.length === 0 &&
pairedKept.length === 0
)
return null;
const bounds = new Set<Node>();
@@ -616,6 +647,10 @@ export function matchBytesLoopVersion(
if (access.index !== access.ref) narrow(access.index, { min: 0, max: SAFE });
}
}
for (const [receiver, nodes] of pairedKept) {
used.add(receiver);
for (const node of nodes) bounds.add(node);
}
const counterChecks: CounterCheck[] = [];
for (const [id, min] of counterMins) {
const info = whileAccesses!.counters.get(id)!;
@@ -658,6 +693,10 @@ export function matchBytesLoopVersion(
counters: counterChecks,
guarded,
counted: affineKept.length > 0 ? counted : null,
paired:
paired !== null && pairedKept.length > 0
? { loop: paired, receivers: pairedKept.map(([receiver]) => renumber.get(receiver)!) }
: null,
counterBound: needsBound ? bound : null,
ranges,
};
@@ -1143,6 +1182,25 @@ export function emitBytesLoopVersionGuard(
conditions.push(both);
}
}
if (version.paired !== null) {
// Both counters stay within [lo, hi] (exact integers below 2^53): every
// access at either one is in bounds when `lo >= 0` and `hi < length`.
const { loop } = version.paired;
const [low, high] = [loop.low.start, loop.high.start].map((expr) => {
const tested = emitExactIntegerTest(host, host.emitExpr(expr).name, -SAFE, SAFE);
conditions.push(tested.ok);
return tested.integer;
});
const nonNegative = B.tmp();
B.line(`${nonNegative} = icmp sge i64 ${low}, 0`);
conditions.push(nonNegative);
for (const receiver of version.paired.receivers) {
// Lengths are at most 2^53 elements: a signed comparison is exact.
const below = B.tmp();
B.line(`${below} = icmp slt i64 ${high}, ${lengths[receiver]}`);
conditions.push(below);
}
}
let valid = "true";
for (const cond of conditions) {
if (valid === "true") valid = cond;
@@ -1156,39 +1214,6 @@ export function emitBytesLoopVersionGuard(
else B.condBr(valid, fast, slow);
}
/** Tests that a double is an exact integer within [min, max] (not -0; NaN
* fails the ordered comparisons) and gives its i64. The integer is frozen,
* so it is a fixed value even when the test fails. */
function emitExactIntegerTest(
host: LlvmEmitterContext,
value: string,
min: number,
max: number,
): { ok: string; integer: string } {
const B = host.B;
const low = B.tmp(),
high = B.tmp(),
inRange = B.tmp(),
raw = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
ok = B.tmp();
B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${raw} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return { ok, integer };
}
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
if (scale === 1) return value;
const scaled = host.B.tmp();
@@ -77,6 +77,65 @@ test("leaf layouts keep direct teardown while recursive layouts share the depth
}
});
test("teardowns of types with children of their own type free their blocks as one segment", () => {
const linked = { kind: "object" as const, className: "Linked" };
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
classes: [
{
name: "Linked",
fields: [
{ name: "next", type: linked },
{ name: "other", type: linked },
],
methods: [],
loc,
},
],
records: [{ id: "branch", fields: [{ name: "next", type: linked }] }],
};
const define = (llvm: string, name: string): string =>
new RegExp(`^define internal [^@]*@${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? "";
const linkedDestroy = `${mangleClassRelease("Linked")}_destroy`;
const branchDestroy = `${mangleRecordRelease("branch")}_destroy`;
for (const workers of [false, true]) {
const llvm = emitLlvmModule({ ...mod, workers }, { pointerBits: 64 });
// Linked's teardown keeps its block and its children's in a segment: it
// releases its own block first and places it by address against its
// first child, a child torn down directly appends to the segment, one
// past the direct chain appends through the runtime, and the chain's
// first teardown frees the segment onto the free list in one step.
const seg = define(llvm, `${linkedDestroy}_s`);
expect(seg.startsWith("define internal ptr"), "segment form").toBe(true);
expect(seg).toMatch(
/store ptr %(?:sf\.blk|o), ptr %sg\.own[^]*call ptr @sc_release_Linked_destroy_s\(/,
);
// The children's releases have a copy for each place of the own block.
for (const copy of ["ta", "tb"])
expect(seg).toContain(
`call ptr @scr_rc_destroy_seg(ptr %v0, ptr @${linkedDestroy}_sc, ptr @${linkedDestroy}, ptr %${copy}0.bt)`,
);
expect(seg).not.toContain("call void @scr_rc_destroy(");
expect(seg).toContain("icmp ult ptr %sg.o, %sg.r1");
const first = define(llvm, `${linkedDestroy}_d`);
expect(first).toContain(`call ptr @${linkedDestroy}_s(ptr %o, i32 %d, ptr %head)`);
expect(first).toContain("store ptr %first, ptr %sgf.fp");
expect(first.includes("llvm.threadlocal.address"), "thread-local state").toBe(workers);
expect(define(llvm, linkedDestroy)).toContain(`call void @${linkedDestroy}_d(ptr %o, i32 0)`);
// A record whose child is another type frees its own block as before.
expect(define(llvm, `${branchDestroy}_s`)).toBe("");
expect(define(llvm, branchDestroy)).toContain(`call void @${mangleClassRelease("Linked")}(`);
}
// Without the inline allocation paths (32-bit targets) a teardown frees
// through the runtime, block by block.
const llvm32 = emitLlvmModule(mod, { pointerBits: 32 });
expect(define(llvm32, `${linkedDestroy}_s`)).toBe("");
expect(define(llvm32, `${linkedDestroy}_d`)).toContain(`call void @${linkedDestroy}_d(`);
});
test("releases split into an inlinable fast path and an out-of-line remainder", () => {
// An array of the class itself makes the hierarchy cycle-capable.
const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } };
+58 -15
View File
@@ -43,6 +43,9 @@ import {
destroyDefinitions,
directChildReleaseLines,
directDestroyTarget,
segmentPrologueLines,
segmentTeardownLines,
segmentedTeardown,
usedDirectTargets,
type DirectDestroy,
type RegionHooks,
@@ -1082,25 +1085,62 @@ export function emitClassShapes(
bounded &&
(directTargets.has(`object:${cls.name}`) ||
refFields.some((f) => directDestroyTarget(host, f.type, directTargets) !== null));
// The teardown's own symbol, and whether it keeps a segment (shapes.ts
// segmentedTeardown).
const destroy = `${meta.hierarchy ? mangleClassReleaseDirect(cls.name) : mangleClassRelease(cls.name)}_destroy`;
const segmented =
chained &&
!meta.compact &&
runtimePrefix === null &&
!isStreamRooted &&
!meta.threadSlot &&
segmentedTeardown(
host,
destroy,
refFields.map((f) => f.type),
directTargets,
);
// The field-releasing teardown body shared by both release shapes
// (the public one on standalone classes, the DIRECT one on hierarchy
// members). Runs at rc == 0.
const teardown = (lines: string[]): void => {
refFields.forEach((f, t) => {
lines.push(
` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
` %v${t} = load ptr, ptr %f${t}`,
);
const release = (prefix: string, f: (typeof refFields)[number], t: number): string[] => {
const direct = bounded ? directDestroyTarget(host, f.type, directTargets) : null;
if (direct)
lines.push(
...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(f.name)),
);
else
lines.push(
if (direct === null)
return [
` call void ${releaseSym(host, f.type)}(ptr %v${t}) ; ${llvmCommentText(f.name)}`,
);
});
];
const own = segmented && direct.destroy === destroy;
return directChildReleaseLines(
host,
`${prefix}${t}`,
`%v${t}`,
direct,
llvmCommentText(f.name),
own,
);
};
const loads = refFields.map((f, t) => [
` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
` %v${t} = load ptr, ptr %f${t}`,
]);
if (segmented) {
// A segmented teardown loads its fields, releases its own block, and
// then releases the fields (shapes.ts segmentTeardownLines).
const ownType = refFields.flatMap((f, t) =>
directDestroyTarget(host, f.type, directTargets)?.destroy === destroy ? [`%v${t}`] : [],
);
lines.push(
...segmentPrologueLines(),
...loads.flat(),
...emitObjectFree(host, traced, "weak", "%sg.own"),
...segmentTeardownLines(ownType, (prefix) =>
refFields.flatMap((f, t) => release(prefix, f, t)),
),
);
return;
}
refFields.forEach((f, t) => lines.push(...loads[t]!, ...release("td", f, t)));
if (runtimePrefix) lines.push(...regCall("td", runtimePrefix.drop, ""));
if (isStreamRooted) lines.push(...stCall("tds", "scr_stream_st_release", ""));
if (meta.threadSlot) lines.push(...slotForgetLines(host, struct, meta, "tdw"));
@@ -1182,10 +1222,12 @@ export function emitClassShapes(
];
if (traced) reld.push(...cycleDeadLines(host, "teardown"));
if (bounded) {
const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`;
const destroyBody: string[] = [];
teardown(destroyBody);
defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``);
defs.push(
...destroyDefinitions(destroy, destroyBody, chained, segmented ? host : null),
``,
);
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`);
} else teardown(reld);
@@ -1207,6 +1249,7 @@ export function emitClassShapes(
bounded,
lifetime,
chained,
segmented,
),
``,
);
@@ -29,6 +29,9 @@ export const SCR_ARR_FIELD = {
* (SCR_ARR_STATES in scr_array.h). */
export const SCR_ARR_STATES = 16;
/** The `spare` flag of a frozen %ScrArr (SCR_ARR_FROZEN in scr_array.h). */
export const SCR_ARR_FROZEN = 32;
/** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a
* size-type count. */
export function loadArrCount(
@@ -1,9 +1,28 @@
import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js";
import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js";
import {
exprFunctionReference,
FUNCTION_REFERENCE_KINDS,
moduleFunctionReferences,
} from "../../ir/function-references.js";
import { writtenLocals } from "../../ir/callback-specialization.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
/** The nodes the body census below reads. */
const CENSUS_KINDS: readonly IrNodeKind[] = [
"varDecl",
"assign",
"forOf",
"rethrow",
"tryCatch",
"call",
"callValue",
"assignExpr",
"incDec",
...FUNCTION_REFERENCE_KINDS,
];
/** Closed direct-call consumers can devirtualize a callback when every
* incoming call supplies the same function. Keep its evaluated environment,
* arguments and ordinary ownership; only the loaded code pointer changes.
@@ -15,6 +34,7 @@ import type { CallLifetimes } from "./call-lifetimes.js";
export function findConstantCallbacks(
mod: IrModule,
lifetimes: CallLifetimes,
index?: IrNodeIndex,
): Map<string, Map<string, string>> {
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const definitions = new Map<string, Map<string, IrExpr>>();
@@ -50,7 +70,7 @@ export function findConstantCallbacks(
const writes = new Set<string>();
// Parameters and captures each function (or a closure sharing the
// binding) writes after entry.
const rebinds = writtenLocals(mod);
const rebinds = writtenLocals(mod, index);
// A function named anywhere but as a direct call's callee can be entered
// by a generated adapter or the runtime, which a direct-call census cannot
// specialize: the entry, exports, class helpers and methods here, and
@@ -66,6 +86,7 @@ export function findConstantCallbacks(
// The initializer of each immutable function-typed local declared once:
// its only write, even when a capture box (not a stable slot) holds it.
const constDefinitions = new Map<string, Map<string, IrExpr | null>>();
const scan = index?.scan(CENSUS_KINDS);
for (const fn of mod.functions) {
const defs = new Map<string, IrExpr>();
const consts = new Map<string, IrExpr | null>();
@@ -85,51 +106,64 @@ export function findConstantCallbacks(
const emptyDecls = new Map<string, number>();
const assigned = new Map<string, IrExpr[]>();
const rebound = new Set<string>();
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "varDecl" && stmt.init?.type.kind === "func" && stable?.has(stmt.localId))
defs.set(stmt.localId, stmt.init);
if (stmt.kind === "varDecl" && immutable.has(stmt.localId))
consts.set(stmt.localId, consts.has(stmt.localId) ? null : stmt.init);
if (stmt.kind === "varDecl" && hoisted.has(stmt.localId)) {
if (stmt.init !== null) rebound.add(stmt.localId);
emptyDecls.set(stmt.localId, (emptyDecls.get(stmt.localId) ?? 0) + 1);
}
if (stmt.kind === "assign" && hoisted.has(stmt.localId)) {
const values = assigned.get(stmt.localId) ?? [];
values.push(stmt.value);
assigned.set(stmt.localId, values);
}
if (stmt.kind === "forOf" || stmt.kind === "rethrow") rebound.add(stmt.localId);
if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) rebound.add(stmt.catchLocalId);
if (stmt.kind === "assign") {
if (globalIds.has(stmt.localId)) {
if (globals.has(stmt.localId)) writes.add(stmt.localId);
else globals.set(stmt.localId, stmt.value);
eachNodeOfKinds(fn, scan, (node) => {
switch (node.kind) {
case "varDecl":
if (node.init?.type.kind === "func" && stable?.has(node.localId))
defs.set(node.localId, node.init);
if (immutable.has(node.localId))
consts.set(node.localId, consts.has(node.localId) ? null : node.init);
if (hoisted.has(node.localId)) {
if (node.init !== null) rebound.add(node.localId);
emptyDecls.set(node.localId, (emptyDecls.get(node.localId) ?? 0) + 1);
}
return;
case "assign":
if (hoisted.has(node.localId)) {
const values = assigned.get(node.localId) ?? [];
values.push(node.value);
assigned.set(node.localId, values);
}
if (globalIds.has(node.localId)) {
if (globals.has(node.localId)) writes.add(node.localId);
else globals.set(node.localId, node.value);
}
return;
case "forOf":
case "rethrow":
rebound.add(node.localId);
return;
case "tryCatch":
if (node.catchLocalId !== null) rebound.add(node.catchLocalId);
return;
case "call":
if (consumers.has(node.callee)) {
const calls = incoming.get(node.callee) ?? [];
calls.push({ owner: fn.name, args: node.args });
incoming.set(node.callee, calls);
}
return;
case "callValue":
if (node.callee.kind === "varRef") callees.add(node.callee.localId);
return;
case "closure": {
escaped.add(node.fnName);
const sites = closureSites.get(node.fnName) ?? [];
sites.push({ owner: fn.name, captures: node.captures });
closureSites.set(node.fnName, sites);
return;
}
return true;
},
expr: (expr) => {
if (expr.kind === "call" && consumers.has(expr.callee)) {
const calls = incoming.get(expr.callee) ?? [];
calls.push({ owner: fn.name, args: expr.args });
incoming.set(expr.callee, calls);
case "orDefault": {
const referenced = exprFunctionReference(node);
if (referenced !== undefined) escaped.add(referenced);
return;
}
if (expr.kind === "callValue" && expr.callee.kind === "varRef")
callees.add(expr.callee.localId);
const referenced = exprFunctionReference(expr);
if (referenced !== undefined) escaped.add(referenced);
if (expr.kind === "closure") {
const sites = closureSites.get(expr.fnName) ?? [];
sites.push({ owner: fn.name, captures: expr.captures });
closureSites.set(expr.fnName, sites);
}
if ((expr.kind === "assignExpr" || expr.kind === "incDec") && globalIds.has(expr.localId))
writes.add(expr.localId);
if (expr.kind === "assignExpr" || expr.kind === "incDec") rebound.add(expr.localId);
return true;
},
case "assignExpr":
case "incDec":
if (globalIds.has(node.localId)) writes.add(node.localId);
rebound.add(node.localId);
return;
}
});
for (const [id, values] of assigned)
if (emptyDecls.get(id) === 1 && values.length === 1 && !rebound.has(id))
@@ -151,3 +151,36 @@ function emitExactStart(host: LlvmEmitterContext, expr: IrExpr): string {
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return ok;
}
/** Tests that a double is an exact integer within [min, max] (not -0; NaN
* fails the ordered comparisons) and gives its i64. The integer is frozen,
* so it is a fixed value even when the test fails. */
export function emitExactIntegerTest(
host: LlvmEmitterContext,
value: string,
min: number,
max: number,
): { ok: string; integer: string } {
const B = host.B;
const low = B.tmp(),
high = B.tmp(),
inRange = B.tmp(),
raw = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
ok = B.tmp();
B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${raw} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return { ok, integer };
}
@@ -505,8 +505,12 @@ export function emitDenseArrayState(
indexExpr: IrExpr | undefined,
kind: "state" | "has",
width: number,
/** The index is a dense array loop counter its entry test proved below
* the hole-free prefix (provenDenseIndex): the slot is a present value. */
proven = false,
): string {
const B = host.B;
if (proven) return kind === "state" ? f64Lit(SCR_ARR_VALUE) : "true";
const fn = kind === "state" ? "scr_arr_state" : "scr_arr_has";
const ty = kind === "state" ? "double" : "i1";
host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`);
@@ -1,9 +1,10 @@
import { expect, test } from "vitest";
import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
countedDenseShape,
matchDenseArrayLoop,
pairedDenseShape,
type DenseLoopShape,
} from "./dense-array-loops.js";
@@ -42,6 +43,8 @@ const locals = new Map<string, IrLocal>([
["a", { id: "a", name: "a", type: ARR, mutable: false }],
["b", { id: "b", name: "b", type: ARR, mutable: true }],
["c", { id: "c", name: "c", type: ARR, mutable: false }],
["j", { id: "j", name: "j", type: F64, mutable: true }],
["t", { id: "t", name: "t", type: F64, mutable: false }],
]);
/** for (let i = 0; i < a.length; i++) body */
@@ -193,3 +196,100 @@ test("a loop to another invariant limit covers each array whose length it keeps"
expect([...matchLimited(resizing, new Set(["b"]))!.arrays.keys()]).toEqual(["a"]);
expect(matchLimited(resizing)).toBeNull();
});
test("a two-counter loop covers the arrays indexed at either counter", () => {
// for (let i = 1, j = k - 1; i < j; i++, j--) { t = a[i]; a[i] = a[j]; a[j] = t; }
const paired: IntegerPairedForLoop = {
low: { localId: "i", start: num(1), step: 1, startGuarded: false },
high: { localId: "j", start: ref("k"), step: -1, startGuarded: true },
inclusive: false,
guarded: true,
range: { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
};
const pairedShape = pairedDenseShape(paired);
const matchPaired = (body: IrStmt[]) =>
matchDenseArrayLoop(loop(body), pairedShape, locals, new Set(), new Set(["a", "b"]));
const swap: IrStmt[] = [
{ kind: "varDecl", localId: "t", init: read("a"), loc },
write("a", read("a", ref("j"))),
write("a", ref("t"), ref("j")),
];
const dense = matchPaired(swap);
expect([...dense!.arrays.keys()]).toEqual(["a"]);
expect(dense!.pair).toEqual({ high: "j", lowStart: num(1), highStart: ref("k") });
expect(dense!.range).toEqual({ min: 0, max: 2147483646 });
// Stores at any other index may extend the array.
expect(matchPaired([...swap, write("a", num(0), ref("k"))])).toBeNull();
});
test("a present-undefined store is covered only where it stores a covered read's missing arm", () => {
// const v = b[i] (as number | undefined);
// if (v is undefined) c[i] = undefined; else c[i] = v is number ? v : throw;
const unionType = { kind: "union" as const, unionId: "u" };
const v: IrExpr = { kind: "varRef", localId: "v", type: unionType, loc };
const isTag = (tag: number): IrExpr => ({
kind: "unionIsTag",
unionId: "u",
tag,
negated: false,
value: v,
type: BOOL,
loc,
});
const reader = (array: string, index = ref("i")): IrExpr => ({
kind: "call",
callee: "%arr.idxOr.f64",
args: [arr(array), index],
type: unionType,
loc,
});
const undefinedStore: IrStmt = { kind: "arraySetUndefined", arr: arr("c"), index: ref("i"), loc };
const guarded = (array: string): IrStmt[] => [
{ kind: "varDecl", localId: "v", init: reader(array), loc },
{
kind: "if",
cond: isTag(1),
then: [undefinedStore],
else_: [
write("c", {
kind: "ternary",
cond: isTag(0),
then: { kind: "unionNarrow", unionId: "u", tag: 0, value: v, type: F64, loc },
else_: {
kind: "libCall",
fn: "error.nodeThrow",
args: [num(1), { kind: "strLit", value: "", type: { kind: "string" }, loc }],
type: F64,
loc,
},
type: F64,
loc,
}),
],
loc,
},
];
const withV = new Map([
...locals,
["v", { id: "v", name: "v", type: unionType, mutable: false }],
]);
const matchStores = (body: IrStmt[], initialized: string[]) =>
matchDenseArrayLoop(loop(body), shape, withV, new Set(), new Set(initialized));
const dense = matchStores(guarded("b"), ["a", "b", "c"]);
expect([...dense!.arrays.keys()].sort()).toEqual(["b", "c"]);
// A read of an array the entry test does not cover may miss in the fast
// copy, and its undefined store would run there.
expect(matchStores(guarded("b"), ["a", "c"])).toBeNull();
// A present undefined stored on its own leaves out its array and every
// array the other name may hold.
const alone: IrStmt[] = [{ kind: "exprStmt", expr: read("b"), loc }, undefinedStore];
expect(matchStores(alone, ["a", "b", "c"])).toBeNull();
const separate = matchDenseArrayLoop(
loop(alone),
shape,
withV,
new Set(["c"]),
new Set(["a", "b", "c"]),
);
expect([...separate!.arrays.keys()]).toEqual(["b"]);
});
@@ -20,16 +20,23 @@
* A counted loop with another invariant limit, such as `j < n` after
* `const n = a.length` or `j < 5`, is covered the same way: the test bounds
* the loop's exclusive limit by 2^31 - 1 and by each indexed array's
* `filled`, and the loop must provably never change any of their lengths. */
* `filled`, and the loop must provably never change any of their lengths.
*
* A two-counter loop closing in on itself, such as
* `for (let i = 1, j = k - 1; i < j; i++, j--)`, indexes only between its
* low counter's start and its high counter's: the low counter only rises
* and stays below the high one, which only falls. Its test bounds the low
* start below by 0 and the high start by 2^31 - 2 and below each indexed
* array's `filled`, both starts being exact integers. */
import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js";
import type { IntegerRange } from "../../ir/integer-ranges.js";
import { preservesArrayLength } from "../../ir/array-length-stability.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { loadArrCount, SCR_ARR_FIELD } from "./common.js";
import { emitCountedLoopLimit } from "./counted-loops.js";
import { emitCountedLoopLimit, emitExactIntegerTest } from "./counted-loops.js";
export const INT32_MAX = 2147483647;
@@ -47,6 +54,17 @@ export interface DenseArrayLoop {
arrays: ReadonlyMap<string, IrExpr & { kind: "varRef" }>;
/** The counter's range on every pass of the fast copy. */
range: IntegerRange;
/** A two-counter loop's high counter and both starts, when the counter
* is its low one (`length` and `limit` are then null). */
pair: DensePair | null;
}
/** The high counter of a two-counter dense loop, and the starts its entry
* test bounds. */
export interface DensePair {
high: string;
lowStart: IrExpr;
highStart: IrExpr;
}
/** The fast copy duplicates the loop; keep the duplicated code small. */
@@ -62,6 +80,8 @@ export interface DenseLoopShape {
/** The counter's proven range on every pass of the ordinary loop; the
* fast copy keeps its minimum, which must be at least 0. */
range: IntegerRange;
/** A two-counter loop's high counter and starts. */
pair?: DensePair;
}
/** `length.length - c` (c an integer literal, 0 when absent). */
@@ -107,6 +127,124 @@ export function countedDenseShape(
return { counter: counted.localId, length: start.receiver, range: counted.range };
}
/** The shape of a two-counter loop whose low counter rises and high
* counter falls: both stay between the two starts, which the entry test
* bounds, so the fast copy's counters lie in [0, 2^31 - 2]. */
export function pairedDenseShape(loop: IntegerPairedForLoop): DenseLoopShape {
return {
counter: loop.low.localId,
length: null,
range: { min: 0, max: INT32_MAX - 1 },
pair: { high: loop.high.localId, lowStart: loop.low.start, highStart: loop.high.start },
};
}
interface ElementRead {
arr: IrExpr;
index: IrExpr;
}
/** The array and index of a call of an element-reader helper
* (`%arr.idxOr.*`, ir/int32-arrays.ts isElementReader): `a[i]` as
* `number | undefined`. */
function elementReaderCall(e: IrExpr): ElementRead | null {
if (e.kind !== "call" || !e.callee.startsWith("%arr.idxOr.") || e.args.length !== 2) return null;
return { arr: e.args[0]!, index: e.args[1]! };
}
/** The array and index of an element read as `T | undefined`: an
* element-reader call, or its body inlined for an int32 array
* (`arrayState(a, i) === 1 ? wrap(a[i]) : wrap(undefined)`). */
function elementRead(e: IrExpr): ElementRead | null {
const call = elementReaderCall(e);
if (call) return call;
if (e.kind !== "ternary") return null;
const cond = e.cond;
if (cond.kind !== "bin" || cond.op !== "===") return null;
const state = cond.left,
present = cond.right,
wrapped = e.then,
missing = e.else_;
if (state.kind !== "arrayState" || present.kind !== "numLit" || present.value !== 1) return null;
if (wrapped.kind !== "unionWrap" || missing.kind !== "unionWrap") return null;
const value = wrapped.value;
if (value.kind !== "arrayGet" || missing.value.kind !== "unitLit") return null;
const same = (x: IrExpr, y: IrExpr): boolean =>
x.kind === "varRef" && y.kind === "varRef" && x.localId === y.localId;
return same(state.arr, value.arr) && same(state.index, value.index)
? { arr: state.arr, index: state.index }
: null;
}
/** The present-undefined stores of `a[i] = b[j]!` as lowering spells it,
* with the element read each one stores the missing arm of:
*
* const v = %arr.idxOr(b, j);
* if (v is undefined) a[i] = undefined; else a[i] = v is number ? v : throw;
*
* The read's local is declared once in the same list as the `if`, and only
* read after it. */
function missingElementStores(body: IrStmt[]): Map<IrStmt, ElementRead> {
const result = new Map<IrStmt, ElementRead>();
const writes = new Map<string, number>();
const count = (id: string): void => void writes.set(id, (writes.get(id) ?? 0) + 1);
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") count(e.localId);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "varDecl" || s.kind === "assign") count(s.localId);
return everyStmtChild(s, expr, stmt);
};
body.every(stmt);
const list = (stmts: IrStmt[]): void => {
const reads = new Map<string, ElementRead>();
for (const s of stmts) {
if (s.kind === "varDecl" && s.init && writes.get(s.localId) === 1) {
const read = elementRead(s.init);
if (read) reads.set(s.localId, read);
}
if (s.kind === "if") {
const store = missingArmStore(s, reads);
if (store) result.set(store.stmt, store.read);
}
// Nested loops keep a loop out of the fast copy (matchDenseArrayLoop),
// and other statement lists leave their stores unmatched.
if (s.kind === "block") list(s.body);
if (s.kind === "if") {
list(s.then);
if (s.else_) list(s.else_);
}
}
};
list(body);
return result;
}
/** The present-undefined store of `if (v is undefined) a[i] = undefined;
* else a[i] = v is number ? v : throw;`, with the read `v` holds. */
function missingArmStore(
s: IrStmt & { kind: "if" },
reads: ReadonlyMap<string, ElementRead>,
): { stmt: IrStmt; read: ElementRead } | null {
const cond = s.cond;
if (cond.kind !== "unionIsTag" || cond.negated || cond.value.kind !== "varRef") return null;
const local = cond.value.localId;
const read = reads.get(local);
if (!read || s.then.length !== 1 || s.else_?.length !== 1) return null;
const store = s.then[0]!,
other = s.else_[0]!;
if (store.kind !== "arraySetUndefined" || other.kind !== "arraySet") return null;
const value = other.value;
if (value.kind !== "ternary") return null;
const test = value.cond,
narrowed = value.then;
if (test.kind !== "unionIsTag" || test.negated || test.tag === cond.tag) return null;
if (test.value.kind !== "varRef" || test.value.localId !== local) return null;
if (narrowed.kind !== "unionNarrow" || narrowed.tag !== test.tag) return null;
return narrowed.type.kind === "f64" ? { stmt: store, read } : null;
}
/** Match the fast copy of a loop of the given shape. `initialized` holds the
* bindings definitely assigned when the loop starts; `unaliased` the local
* arrays no other binding holds. */
@@ -118,6 +256,8 @@ export function matchDenseArrayLoop(
initialized: ReadonlySet<string>,
): DenseArrayLoop | null {
const { counter, length } = shape;
const high = shape.pair?.high ?? null;
const isCounter = (id: string): boolean => id === counter || id === high;
if (shape.range.min < 0 || !loop.cond) return null;
let nodes = 0,
nested = false;
@@ -126,13 +266,21 @@ export function matchDenseArrayLoop(
// element write.
const disturbed = new Set<string>();
const access = (arr: IrExpr, index: IrExpr): void => {
if (arr.kind === "varRef" && index.kind === "varRef" && index.localId === counter)
if (arr.kind === "varRef" && index.kind === "varRef" && isCounter(index.localId))
candidates.set(arr.localId, arr);
};
const missing = missingElementStores(loop.body);
// The element reads behind the guarded present-undefined stores.
const guards: ElementRead[] = [];
// The arrays of the other present-undefined stores. The fast copy runs
// them, and the slot each one writes leaves the hole-free prefix.
const undefinedStores: IrExpr[] = [];
const expr = (e: IrExpr): boolean => {
if (++nodes > MAX_LOOP_NODES) return false;
if (e.kind === "assignExpr" || e.kind === "incDec") disturbed.add(e.localId);
if (e.kind === "arrayGet") access(e.arr, e.index);
const reader = elementReaderCall(e);
if (reader) access(reader.arr, reader.index);
if (e.kind === "arrIntrinsic") {
if (e.method === "getNumber" && e.args[0]) access(e.receiver, e.args[0]);
else if (e.method !== "length" && e.receiver.kind === "varRef")
@@ -147,9 +295,21 @@ export function matchDenseArrayLoop(
if (s.kind === "assign" || s.kind === "varDecl") disturbed.add(s.localId);
if (s.kind === "arraySet") {
access(s.arr, s.index);
if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || s.index.localId !== counter))
if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || !isCounter(s.index.localId)))
disturbed.add(s.arr.localId);
}
if (s.kind === "arraySetUndefined") {
// A present undefined at a counter only where it stores a missing
// element read (`a[i] = b[j]!`): the fast copy reads a present value
// there, so the store never runs in it.
const read = missing.get(s);
if (read === undefined || s.index.kind !== "varRef" || !isCounter(s.index.localId)) {
undefinedStores.push(s.arr);
} else {
access(s.arr, s.index);
guards.push(read);
}
}
return everyStmtChild(s, expr, stmt);
};
if (!loop.body.every(stmt) || !expr(loop.cond) || nested) return null;
@@ -164,6 +324,15 @@ export function matchDenseArrayLoop(
)
)
return null;
// A store through another name, such as `const b = a` or a field, lands
// in `a` unless one of the two bindings is a local array no other binding
// holds.
const mayStoreUndefined = (id: string): boolean =>
undefinedStores.some((arr) =>
arr.kind === "varRef"
? arr.localId === id || (!unaliased.has(id) && !unaliased.has(arr.localId))
: !unaliased.has(id),
);
const arrays = new Map<string, IrExpr & { kind: "varRef" }>();
for (const [id, ref] of candidates) {
const local = locals.get(id);
@@ -173,6 +342,7 @@ export function matchDenseArrayLoop(
local.boxed ||
local.tdz ||
disturbed.has(id) ||
mayStoreUndefined(id) ||
(id !== length?.localId && !initialized.has(id))
)
continue;
@@ -180,18 +350,33 @@ export function matchDenseArrayLoop(
// once: the loop must not change any of their lengths.
if (
length === null &&
!preservesArrayLength(nodesOfLoop, { receiver: id, inRange: counter }, locals, unaliased)
!preservesArrayLength(
nodesOfLoop,
{ receiver: id, inRange: counter, inRangeHigh: high },
locals,
unaliased,
)
)
continue;
arrays.set(id, ref);
}
if (arrays.size === 0) return null;
// Each guarded store's read must be one the fast copy proves present.
for (const { arr, index } of guards)
if (
arr.kind !== "varRef" ||
!arrays.has(arr.localId) ||
index.kind !== "varRef" ||
!isCounter(index.localId)
)
return null;
return {
counter,
length,
limit: length === null ? (shape.limit ?? null) : null,
arrays,
range: { min: shape.range.min, max: Math.min(shape.range.max, INT32_MAX - 1) },
pair: shape.pair ?? null,
};
}
@@ -207,6 +392,7 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
if (loop.pair !== null) return emitPairedDenseGuard(host, loop, loop.pair, header);
if (loop.length === null) {
// A limit at or below the start runs no pass, and passes the test.
const limit = emitCountedLoopLimit(host, loop.limit!);
@@ -254,3 +440,36 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
}
return valid;
}
/** A two-counter loop's entry test: both starts are exact integers, the
* low one at least 0 and the high one at most 2^31 - 2 and below every
* indexed array's hole-free prefix bound. The starts are stable values
* (matchIntegerPairedForLoop), so evaluating them here again is unobservable. */
function emitPairedDenseGuard(
host: LlvmEmitterContext,
loop: DenseArrayLoop,
pair: DensePair,
header: (array: string, field: number) => string,
): string {
const B = host.B;
const start = (expr: IrExpr, min: number, max: number) =>
emitExactIntegerTest(host, host.emitExpr(expr).name, min, max);
const low = start(pair.lowStart, 0, INT32_MAX - 1);
const high = start(pair.highStart, -1, INT32_MAX - 1);
let valid = B.tmp();
B.line(`${valid} = and i1 ${low.ok}, ${high.ok}`);
for (const ref of loop.arrays.values()) {
const array = host.emitStableReceiver(ref, []);
const filledPtr = header(array.name, SCR_ARR_FIELD.filled),
filled = B.tmp(),
wide = B.tmp(),
covered = B.tmp(),
both = B.tmp();
B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
B.line(`${wide} = zext i32 ${filled} to i64`);
B.line(`${covered} = icmp slt i64 ${high.integer}, ${wide}`);
B.line(`${both} = and i1 ${valid}, ${covered}`);
valid = both;
}
return valid;
}
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
import { DYN_HANDLE_KINDS } from "../../ir/ir.js";
import { DYN_HANDLE_TAG_NUM } from "./dyn.js";
import { DYN_HANDLE_TAG_NUM, TEXT_CODEC_TAG_NUM } from "./dyn.js";
// SCR_DYNH_TAGS in scr_dyn.h lists the ScrDynHandleTag values by name, each
// with an explicit value, so changes that add tags edit different lines. The
@@ -39,3 +39,9 @@ test("the backend's handle tag numbers match scr_dyn.h", () => {
expect(values.get(tag!), `${kind} (${tag})`).toBe(num);
}
});
test("the backend's codec handle tag numbers match scr_dyn.h", () => {
const values = new Map(tags.map((tag) => [tag.name, tag.value]));
expect(values.get("SCR_DYNH_TEXT_DECODER")).toBe(TEXT_CODEC_TAG_NUM.TextDecoder);
expect(values.get("SCR_DYNH_TEXT_ENCODER")).toBe(TEXT_CODEC_TAG_NUM.TextEncoder);
});
+157 -4
View File
@@ -43,6 +43,8 @@ import {
isDynTypedRefType,
isRefCounted,
isUnitType,
recordTextCodecClass,
textCodecRecordClass,
typeKey,
} from "../../ir/ir.js";
import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
@@ -94,6 +96,17 @@ export const DYN_HANDLE_TAG_NUM: Record<string, number> = {
cryptoHmac: 53,
};
/** The handle tags of TextEncoder and TextDecoder records boxed as dyn
* (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */
export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = {
TextDecoder: 76,
TextEncoder: 77,
};
/** The decoder record fields scr_text_decoder_to_dyn reads, in its
* parameter order. */
const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const;
/** Handle kinds whose runtime unit boxes them through its own function,
* which installs the tag's ops on first use (scr_crypto.h). Programs that
* never box one of these values do not link its dynamic members. */
@@ -370,6 +383,36 @@ export class LlDyn {
B.line(`call void @scr_dyn_build_add(ptr %d, ptr @${name}, ptr ${value})`);
}
/** The order a dyn value tries a union's arms in: the canonical order,
* except that among the record arms, those with more required fields go
* first, then those with more fields. Records are width-tolerant, so an
* arm with fewer fields (`{}`, or one whose fields are all optional,
* matches every object) would otherwise take an object that a wider arm
* also matches and drop the fields only the wider arm has. */
private unionArmOrder(def: IrUnionDef): number[] {
const width = (i: number): [number, number] | null => {
const arm = def.arms[i]!;
if (arm.kind !== "record") return null;
const shape = this.host.recordsById.get(arm.shapeId);
if (!shape || shape.tuple) return null;
const required = shape.fields.filter(
(field) =>
field.type.kind !== "union" ||
!this.host.unionsById.get(field.type.unionId)?.arms.some((x) => x.kind === "undefinedT"),
).length;
return [required, shape.fields.length];
};
const order = def.arms.map((_, i) => i);
const records = order.filter((i) => width(i) !== null);
const widest = [...records].sort((x, y) => {
const [rx, fx] = width(x)!;
const [ry, fy] = width(y)!;
return ry - rx || fy - fx || x - y;
});
records.forEach((slot, k) => (order[slot] = widest[k]!));
return order;
}
/** Branch to a variant only after its literal discriminator and complete
* structure match. Use the same path for predicates and builders. */
private unionArmMatch(
@@ -897,6 +940,21 @@ export class LlDyn {
const name = `sc_dm_${this.dynMatchers.size}`;
this.dynMatchers.set(matcherKey, name);
this.host.journalAllocated?.(name, "dyn.dynMatchHelper", [t, preserveRefs]);
const codec = textCodecRecordClass(t, (id) => this.host.recordsById.get(id));
if (codec !== null) {
// A codec matches only its own handle box.
const B = new BlockBuilder();
const matched = this.handleTagMatches(B, TEXT_CODEC_TAG_NUM[codec]);
B.terminate(`ret i1 ${matched}`);
this.defs.push(
`define internal zeroext i1 @${name}(ptr %d) ${FN_ATTRS} { ; matches ${key}`,
B.render(),
`}`,
``,
);
this.host.journalBuilt?.();
return name;
}
const recursive = this.isRecursiveShape(t);
const wrapped = recursive || this.holdsRecursiveShape(t);
const B = new BlockBuilder();
@@ -1626,6 +1684,24 @@ export class LlDyn {
this.host.journalBuilt?.();
return name;
}
const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id));
if (codec !== null) {
// The record the codec's handle box holds (+1), or the path TypeError.
host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
const checked = B.tmp();
B.line(
`${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
);
B.terminate(`ret ptr ${checked}`);
this.defs.push(
`define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`,
B.render(),
`}`,
``,
);
this.host.journalBuilt?.();
return name;
}
if (isRefCounted(t) && t.kind !== "dyn") {
host.declare(`declare ptr @scr_dyn_typed_ref_unbox(ptr)`);
if (t.kind === "object" && t.className === "%Error") {
@@ -2381,9 +2457,10 @@ export class LlDyn {
throw new InternalCompilerError(
`llvm emitter bug: dynCheck of unknown union ${t.unionId}`,
);
// Arms in CANONICAL order, first FULL match wins. The matched
// arm's builder can no longer fail.
def.arms.forEach((arm, i) => {
// Arms in unionArmOrder, first FULL match wins. The matched arm's
// builder can no longer fail.
for (const i of this.unionArmOrder(def)) {
const arm = def.arms[i]!;
const lHit = B.newLabel("dcu.h");
const lNext = B.newLabel("dcu.n");
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs);
@@ -2427,7 +2504,7 @@ export class LlDyn {
B.terminate(`ret ptr ${u}`);
}
B.startBlock(lNext);
});
}
if (preserveRefs) {
B.line(
`call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`,
@@ -2653,6 +2730,77 @@ export class LlDyn {
return name;
}
/** `i1`: %d is a handle box with tag `tag`. */
private handleTagMatches(B: BlockBuilder, tag: number): string {
const kd = this.kindOf(B, "%d");
const isHandle = B.tmp();
B.line(`${isHandle} = icmp eq i32 ${kd}, ${DYN_KIND.HANDLE}`);
const lHandle = B.newLabel("dm.handle");
const lDone = B.newLabel("dm.handle.done");
const entry = B.currentLabel();
B.condBr(isHandle, lHandle, lDone);
B.startBlock(lHandle);
const tagPtr = B.tmp();
const loaded = B.tmp();
B.line(
`${tagPtr} = getelementptr inbounds i8, ptr %d, i64 ${this.abiOffset(24, 20)} ; ->v.handle.tag`,
);
B.line(`${loaded} = load i32, ptr ${tagPtr}`);
const same = B.tmp();
B.line(`${same} = icmp eq i32 ${loaded}, ${tag}`);
B.br(lDone);
B.startBlock(lDone);
const matched = B.tmp();
B.line(`${matched} = phi i1 [ false, %${entry} ], [ ${same}, %${lHandle} ]`);
return matched;
}
/** A TextEncoder or TextDecoder record boxes as its handle (+1), which
* holds the record by reference. A decoder passes the byte offsets of the
* fields its boxed decode reads. */
private textCodecToDyn(
B: BlockBuilder,
t: IrType & { kind: "record" },
shape: IrRecordShape,
codec: "TextEncoder" | "TextDecoder",
): void {
const host = this.host;
const rc = vAdapters(host, t);
const r = B.tmp();
if (codec === "TextEncoder") {
host.declare(`declare ptr @scr_text_encoder_to_dyn(ptr, ptr, ptr)`);
B.line(
`${r} = call ptr @scr_text_encoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release})`,
);
} else {
const struct = mangleRecordStruct(t.shapeId);
const base = host.nullableUnions.fieldBase(t.shapeId);
const start = B.tmp();
B.line(`${start} = ptrtoint ptr %v to ${this.S}`);
const offsets = TEXT_DECODER_FIELDS.map((field) => {
const index = shape.fields.findIndex((f) => f.name === field);
if (index < 0)
throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`);
const p = B.tmp();
const at = B.tmp();
const offset = B.tmp();
B.line(
`${p} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${index + base} ; .${llvmCommentText(field)}`,
);
B.line(`${at} = ptrtoint ptr ${p} to ${this.S}`);
B.line(`${offset} = sub ${this.S} ${at}, ${start}`);
return `${this.S} ${offset}`;
});
host.declare(
`declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`,
);
B.line(
`${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
);
}
B.terminate(`ret ptr ${r}`);
}
/* ── toDynHelper (walkers.ts, ported) ─────────────────────────── */
/** `sc_td_<n>(<T> v) -> ptr` — build a fresh dyn value from a
@@ -2828,6 +2976,11 @@ export class LlDyn {
const shape = host.recordsById.get(t.shapeId);
if (!shape)
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
const codec = recordTextCodecClass(shape);
if (codec !== null) {
this.textCodecToDyn(B, t, shape, codec);
break;
}
const struct = mangleRecordStruct(t.shapeId);
const base = host.nullableUnions.fieldBase(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base]));
@@ -52,13 +52,19 @@ import {
matchIntegerPairedForLoop,
} from "../../ir/integer-loops.js";
import { emitByteWindowGuard, matchByteWindow } from "./byte-windows.js";
import { countedDenseShape, emitDenseLoopGuard, matchDenseArrayLoop } from "./dense-array-loops.js";
import {
countedDenseShape,
emitDenseLoopGuard,
matchDenseArrayLoop,
pairedDenseShape,
} from "./dense-array-loops.js";
import {
emitCountedLoopLimit,
emitCountedLoopSafety,
emitPairedLoopSafety,
} from "./counted-loops.js";
import { emitBytesLoopVersionGuard, type BytesLoopVersion } from "./bytes-loop-versions.js";
import { bytesHeaderAlias } from "./bytes-alias.js";
import { mangleLocal } from "../mangle.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlValue } from "./expr-context.js";
@@ -966,7 +972,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
: countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0
? countedDenseShape(s, countedLoop)
: null;
: pairedLoop
? pairedDenseShape(pairedLoop)
: null;
const dense =
denseShape && !window
? matchDenseArrayLoop(
@@ -1043,15 +1051,17 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
LOOPS.of(emitter).unaliasedArrays,
)
: countedLoop;
const version = versionable ? emitter.matchBytesLoopVersion(s, versionCounted) : null;
const version = versionable
? emitter.matchBytesLoopVersion(s, versionCounted, versionCounted ? null : pairedLoop)
: null;
if (version) {
emitVersionedLoop(emitter, s, version);
break;
}
let countedJoin: string | null = null;
if (
(countedLoop?.guarded && !LOOPS.of(emitter).provenCountedGuards.has(s)) ||
pairedLoop?.guarded
(countedLoop?.guarded || pairedLoop?.guarded) &&
!LOOPS.of(emitter).provenCountedGuards.has(s)
) {
const safe = countedLoop
? emitCountedLoopSafety(emitter, countedLoop)
@@ -1144,11 +1154,15 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.line(`${start} = fptosi double ${value.name} to i64`);
B.line(`store i64 ${start}, ptr ${slot}`);
pairedSlots.push(slot);
const dense = LOOPS.of(emitter).denseLoop;
emitter.integerLoopBindings.set(counter.localId, {
slot,
type: "i64",
signed: true,
range: pairedLoop.range,
range:
dense?.pair && dense.counter === pairedLoop.low.localId
? dense.range
: pairedLoop.range,
});
}
} else if (s.init) {
@@ -1218,10 +1232,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
B.line(`${inBounds} = icmp ult i32 ${narrow}, ${len}`);
} else {
emitter.markMemoryPointer(lenPtr, "bytes:header");
B.line(
`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}`,
);
B.line(`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`);
B.line(`${inBounds} = icmp ult ${emitter.sizeType} ${index}, ${len}`);
}
B.condBr(inBounds, lb, exit);
@@ -2085,7 +2096,8 @@ function emitVersionedLoop(
if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
if (version.ranges.size > 0)
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
if (version.counted?.guarded) LOOPS.of(emitter).provenCountedGuards.add(s);
if (version.counted?.guarded || version.paired?.loop.guarded)
LOOPS.of(emitter).provenCountedGuards.add(s);
emitter.emitStmt(s);
B.br(done);
} finally {
@@ -459,6 +459,33 @@ test("class brand probes borrow a local only across an inert literal key", () =>
expect(computed).toContain("call ptr @scr_dyn_retain_v(");
});
test("class constructor probes test the function tag and borrow across a literal key", () => {
const dyn: IrType = { kind: "dyn" };
const key: IrExpr = { kind: "strLit", value: "classval:Example", type: STRING, loc: receiverLoc };
const probe: IrExpr = {
kind: "libCall",
fn: "dyn.classIs",
args: [ref(dyn), key],
type: BOOL,
loc: receiverLoc,
};
const borrowed = work(probe, dyn);
expect(borrowed).not.toContain("call ptr @scr_dyn_retain_v(");
expect(borrowed).toContain("icmp ne ptr");
expect(borrowed).toContain("icmp eq i32 %t");
expect(borrowed).toMatch(/icmp eq i32 %t\d+, 8\b/);
expect(borrowed).toContain("call zeroext i1 @scr_dyn_class_is_key(");
const computed = work(
{
...probe,
args: [ref(dyn), { kind: "toString", operand: ref(dyn), type: STRING, loc: receiverLoc }],
},
dyn,
);
expect(computed).toContain("call ptr @scr_dyn_retain_v(");
expect(computed).toContain("call zeroext i1 @scr_dyn_class_is_key(");
});
test("class brand probes compare the program's key constant before the key text", () => {
const dyn: IrType = { kind: "dyn" };
const key: IrExpr = { kind: "strLit", value: "object:Example", type: STRING, loc: receiverLoc };
@@ -472,6 +499,27 @@ test("class brand probes compare the program's key constant before the key text"
const named = work(probe("dyn.typedRefIs"), dyn);
expect(named).toContain("; ->v.typed_ref.type_key");
expect(named).toContain("@scr_dyn_typed_ref_is_key");
// Another key pointer is rejected on its length and last eight bytes
// ("Example" plus the prefix's ':') before the out-of-line comparison.
expect(named).toContain("; ->v.typed_ref.type_key_len");
expect(named).toContain("and i32 %t");
expect(named).toMatch(/icmp eq i32 %t\d+, 14\b/);
const tail = Buffer.from("object:Example").readBigInt64LE(6);
expect(named).toContain(`icmp eq i64 %t`);
expect(named).toContain(`, ${tail}`);
const shortKey: IrExpr = { kind: "strLit", value: "o:E", type: STRING, loc: receiverLoc };
const short = work(
{
kind: "libCall",
fn: "dyn.typedRefIs",
args: [ref(dyn), shortKey],
type: BOOL,
loc: receiverLoc,
},
dyn,
);
expect(short).toMatch(/icmp eq i32 %t\d+, 3\b/);
expect(short).not.toContain("load i64");
// The key-pointer test answers false for capsules boxed elsewhere.
const exact = work(probe("dyn.typedRefKeyIs"), dyn);
expect(exact).toContain("; ->v.typed_ref.type_key");
+120 -38
View File
@@ -60,7 +60,7 @@ import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion } from "./bytes-loop-versions.js";
import { globalSlotAlias } from "./bytes-alias.js";
import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
materializeSplitPiece,
type SplitSpan,
@@ -68,7 +68,7 @@ import {
type StringSplit,
} from "./split-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { addRecordResultEntries } from "./record-results.js";
import { addRecordResultEntries, resultEntryName } from "./record-results.js";
import { specializeCallbackCalls } from "../../ir/callback-specialization.js";
import {
type ChunkResult,
@@ -82,6 +82,7 @@ import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import {
allocatedLiteral,
fillAllocationLength,
int32ArrayKey,
selectInt32Arrays,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
@@ -169,6 +170,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
import { IrNodeIndex } from "../../ir/node-index.js";
import { lastUseRead } from "./local-extents.js";
import { findReboundParameters } from "./rebound-parameters.js";
import { ReferenceEffects } from "./reference-effects.js";
@@ -184,7 +186,7 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js";
import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -509,6 +511,15 @@ function provenInt32Arrays(mod: IrModule): ReadonlySet<string> {
return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]);
}
/** The functions whose calls the backend may emit as calls of their
* result-slot entries (record-results.ts). */
function resultEntryTargets(mod: IrModule): ReadonlySet<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const targets = new Set<string>();
for (const name of names) if (names.has(resultEntryName(name))) targets.add(name);
return targets;
}
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
@@ -544,6 +555,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
),
),
provenInt32Arrays,
resultEntryTargets,
)
: mod,
options,
@@ -780,7 +792,7 @@ export class LlEmitter {
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind !== "varDecl" && s.kind !== "assign") return true;
const key = `${fn.name}\0${s.localId}`;
const key = int32ArrayKey(fn.name, s.localId);
const checked = checkedInt32Arrays.has(key);
if (!checked && !int32Arrays.has(key)) return true;
const value = s.kind === "varDecl" ? s.init : s.value;
@@ -798,15 +810,16 @@ export class LlEmitter {
});
}
/** Whether `arr` is one of the current function's int32 arrays
* (ir/int32-arrays.ts, kept by the int32 slot analysis). */
/** Whether `arr` reads one of the int32 arrays (ir/int32-arrays.ts, kept
* by the int32 slot analysis): a local or parameter of the current
* function, or a module global. */
int32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef") return false;
if (this.int32KindArray(arr)) return true;
if (this.int32Slots.int32Arrays.size === 0) return false;
return (
this.int32ArrayTemps.has(arr.localId) ||
this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`)
this.int32Slots.int32Arrays.has(int32ArrayKey(this.currentFunctionName, arr.localId))
);
}
@@ -973,8 +986,8 @@ export class LlEmitter {
}
/** Whether an element access at `index` of `arr` lies in the hole-free
* prefix: the counter of a dense array loop's fast copy indexing one of
* the arrays its entry test covered. */
* prefix: a counter of a dense array loop's fast copy indexing one of the
* arrays its entry test covered. */
provenDenseIndex(arr: IrExpr, index: IrExpr): boolean {
const loop = LOOPS.of(this).denseLoop;
return (
@@ -982,8 +995,8 @@ export class LlEmitter {
arr.kind === "varRef" &&
loop.arrays.has(arr.localId) &&
index.kind === "varRef" &&
index.localId === loop.counter &&
this.integerLoopBindings.has(loop.counter)
(index.localId === loop.counter || index.localId === loop.pair?.high) &&
this.integerLoopBindings.has(index.localId)
);
}
@@ -1183,6 +1196,9 @@ export class LlEmitter {
/** Module constants that keep one value once initialized
* (immutability.ts stableGlobals). */
stableGlobals: ReadonlySet<string> = new Set();
/** Every function body's nodes in walk order (ir/node-index.ts), for the
* constructor's analyses. */
readonly nodeIndex: IrNodeIndex;
private readonly tdzGlobals = new Map<string, string>();
readonly constantNumericTables: ReadonlyMap<string, ConstantNumericTable>;
/** Constant-key reads on checked-dynamic objects, each with its own
@@ -1251,7 +1267,7 @@ export class LlEmitter {
/** The module's runtime features (moduleRuntimeFeatures), found with one
* walk of the module the first time any of them is read. */
get runtimeFeatures(): RuntimeFeatures {
return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod));
return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod, this.nodeIndex));
}
/** Whether timer and tick scheduling goes through the *_als entry
* points (RuntimeFeatures asyncContext). */
@@ -1548,8 +1564,15 @@ export class LlEmitter {
((options.functionWorkers ?? 1) > 1 &&
options.forkPrimitives !== undefined &&
options.debugSources === undefined);
this.stackTraces = observesStacks(mod);
this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined);
// One walk of every body, which the analyses below read instead of
// walking the bodies themselves.
this.nodeIndex = new IrNodeIndex(mod);
this.stackTraces = observesStacks(mod, this.nodeIndex);
this.nullableUnions = new NullableUnions(
mod,
options.debugSources === undefined,
this.nodeIndex,
);
this.mockTimers = this.runtimeFeatures.mockTimers;
this.debug =
options.debugSources === undefined
@@ -1562,19 +1585,20 @@ export class LlEmitter {
this.nullableUnions.armTypes(),
);
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.initializerBindings = findInitializerBindings(mod, this.nodeIndex);
// One walk finds the dynamic field stores every analysis below consults.
const dynamicStores = dynamicFieldStores(mod);
const dynamicStores = dynamicFieldStores(mod, undefined, undefined, this.nodeIndex);
this.int32Slots = analyzeInt32Slots(mod, {
dynamicStores,
initializerBindings: this.initializerBindings,
nodes: this.nodeIndex,
});
this.liveViewsReadOnly =
mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0;
this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map();
this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []);
this.programLifetime = new Set(mod.contracts?.programLifetime ?? []);
this.finalFields = analyzeFinalFields(mod, false, dynamicStores);
this.finalFields = analyzeFinalFields(mod, false, dynamicStores, this.nodeIndex);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
@@ -1622,7 +1646,7 @@ export class LlEmitter {
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
for (const fn of mod.functions) this.fnByName.set(fn.name, fn);
this.appendedArrayLiterals = findAppendedArrayLiterals(mod);
this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex);
for (const entry of mod.ffiImports ?? []) {
this.ffiByName.set(entry.name, entry);
}
@@ -1630,10 +1654,11 @@ export class LlEmitter {
// and per async fiber; WASI, Windows and library builds have none.
const triple = (options.targetTriple ?? "").toLowerCase();
const windows = triple === "" ? process.platform === "win32" : triple.includes("windows");
this.publishedTypes = computePublishedTypes(mod);
this.publishedTypes = computePublishedTypes(mod, this.nodeIndex);
const mt = computeMayThrow(mod, {
stackChecks: !this.wasi && mod.lib === undefined && !windows,
published: this.publishedTypes,
nodes: this.nodeIndex,
});
this.mayThrow = mt.fns;
this.workerEntryPolls = mt.workerEntryPolls ?? null;
@@ -1692,12 +1717,14 @@ export class LlEmitter {
new Set((mod.globals ?? []).map((g) => g.id)),
{ unions: mod.unions, records: mod.records },
this.immutableFields,
this.nodeIndex,
);
const walkParameters = new Map<string, Set<number>>();
if (this.debug === null) {
const host = {
pointerLocal: (local: IrLocal) => this.plainReference(local.type),
borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e),
nodes: this.nodeIndex,
};
for (const fn of this.fnByName.values()) {
const preserving = this.referenceEffects.functions.has(fn.name);
@@ -1750,7 +1777,7 @@ export class LlEmitter {
);
this.lentStringParameters =
this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map();
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes);
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex);
// Integer clones signal a stack overflow through their result, which
// needs the single-threaded guard; worker programs also poll for
// termination, and debug builds and stack traces keep every frame.
@@ -1892,7 +1919,7 @@ export class LlEmitter {
this.globalTypes.set(g.id, g.type);
if (g.tdz) this.tdzGlobals.set(g.id, g.name);
}
this.stableGlobals = stableGlobals(mod);
this.stableGlobals = stableGlobals(mod, this.nodeIndex);
// Runtime error, EventEmitter, stream, EventTarget and Event classes
// have known layouts. Subclasses embed the runtime prefixes from
// classes.ts.
@@ -4236,6 +4263,7 @@ export class LlEmitter {
matchBytesLoopVersion(
s: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
paired: IntegerPairedForLoop | null = null,
): ReturnType<typeof matchBytesLoopVersion> {
const fn = LOOPS.of(this).bytesLoopFunction;
if (!fn) return null;
@@ -4245,17 +4273,22 @@ export class LlEmitter {
const invariantGlobals = new Set(this.stableGlobals);
for (const global of this.initializerBindings.get(fn.name) ?? [])
invariantGlobals.add(global.id);
return matchBytesLoopVersion(s, counted, {
locals: this.currentLocals,
invariantGlobals,
captured: this.captureIds,
ranges: this.integerRanges,
initialized,
proven: this.bytesBounds,
workers: this.mod.workers === true,
owned: this.bytesOwned,
scalarStrings: new Set(this.stringSlices.keys()),
});
return matchBytesLoopVersion(
s,
counted,
{
locals: this.currentLocals,
invariantGlobals,
captured: this.captureIds,
ranges: this.integerRanges,
initialized,
proven: this.bytesBounds,
workers: this.mod.workers === true,
owned: this.bytesOwned,
scalarStrings: new Set(this.stringSlices.keys()),
},
paired,
);
}
/** The alias tag of a whole-slot load of a binding (module globals only). */
@@ -6264,6 +6297,41 @@ export class LlEmitter {
return ok;
}
/** Branches on "the array `arr` is frozen" (SCR_ARR_FROZEN in its storage
* flags, scr_array.h), or published too when `published`; like
* emitPublishedTest, leaves the builder in the cold block and answers the
* continuation label. */
private emitFrozenArrayTest(arr: string, published: boolean): string {
const B = this.B;
const sparep = B.tmp(),
spare = B.tmp(),
bit = B.tmp();
let frozen = B.tmp();
B.line(
`${sparep} = getelementptr inbounds %ScrArr, ptr ${arr}, i32 0, i32 ${SCR_ARR_FIELD.spare}`,
);
B.line(`${spare} = load i16, ptr ${sparep}`);
B.line(`${bit} = and i16 ${spare}, ${SCR_ARR_FROZEN}`);
B.line(`${frozen} = icmp ne i16 ${bit}, 0`);
if (published) {
const rc = B.tmp(),
immortal = B.tmp(),
either = B.tmp();
B.line(`${rc} = load ${this.sizeType}, ptr ${arr}`);
B.line(`${immortal} = icmp eq ${this.sizeType} ${rc}, -1`);
B.line(`${either} = or i1 ${frozen}, ${immortal}`);
frozen = either;
}
this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
const expected = B.tmp();
B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`);
const cold = B.newLabel("arr.frozen"),
ok = B.newLabel("arr.ok");
B.condBr(expected, cold, ok);
B.startBlock(cold);
return ok;
}
private publishedNamesSym: string | null = null;
/** The class-name table the hierarchy field guards index by preorder. */
@@ -6328,15 +6396,29 @@ export class LlEmitter {
op: number,
index = f64Lit(0),
count = f64Lit(0),
third: string | null = null,
): void {
if (this.publishedTypes?.containerGuarded(arrType) !== true) return;
const published = this.publishedTypes;
if (published?.containerGuarded(arrType) !== true) return;
const B = this.B;
const ok = this.emitPublishedTest(arr);
this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
const guards = published.arrayGuards(arrType);
const ok = guards.frozen
? this.emitFrozenArrayTest(arr, guards.published)
: this.emitPublishedTest(arr);
const threw = B.tmp();
B.line(
`${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
);
if (third === null) {
this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
B.line(
`${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
);
} else {
this.declare(
`declare zeroext i1 @scr_throw_published_array_range(i32, ptr, double, double, double)`,
);
B.line(
`${threw} = call zeroext i1 @scr_throw_published_array_range(i32 ${op}, ptr ${arr}, double ${index}, double ${count}, double ${third})`,
);
}
const unwind = B.newLabel("pub.throw");
B.condBr(threw, unwind, ok);
B.startBlock(unwind);
@@ -44,6 +44,7 @@ import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { emitCallArrayRead } from "./local-array-reads.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { emitStackMapRead } from "./map-read-lifetimes.js";
@@ -166,6 +167,9 @@ export function emitCallExpr(
return { name: host.emitOwnedNullableStack(a).box, type: a.type };
// An inline union field read copies its tag and bits.
if (host.inlineFieldGet(a)) return host.emitReadReceiver(a);
// A scalar keyed record read writes its tag and bits.
if (a.kind === "recordKeyGet" && scalarRecordKeyGet(host, a))
return { name: emitStackRecordKeyGet(host, a), type: a.type };
}
if (inputs[index]) return host.emitReadReceiver(a);
}
@@ -257,6 +257,16 @@ function emitDiscarded(host: LlvmEmitterContext, e: IrExpr): void {
const start = host.emitExpr(e.args[0]!);
const count = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
const values = items.map((item) => host.emitExpr(item));
// Before the values move into the buffer: a throw releases them with the
// frame.
host.emitPublishedArrayGuard(
r.name,
e.receiver.type,
10,
start.name,
count,
f64Lit(values.length),
);
if (acc === "ref") values.forEach((value) => host.toHeap(value));
let buffer = "null";
if (values.length > 0) {
@@ -978,7 +988,25 @@ export function emitArrIntrinsic(
case "fill":
case "fillUndefined": {
const args = e.args.map((arg) => host.emitExpr(arg));
host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
// fill's range is its last two arguments; copyWithin's target,
// start and end are its three.
if (e.method === "copyWithin")
host.emitPublishedArrayGuard(
r.name,
e.receiver.type,
9,
args[0]!.name,
args[1]!.name,
args[2]!.name,
);
else
host.emitPublishedArrayGuard(
r.name,
e.receiver.type,
8,
args[args.length - 2]!.name,
args[args.length - 1]!.name,
);
if (host.checkedFills.has(e)) {
// A new int32 array with checked stores, filled with a value that
// may not be an int32: widen it first then.
@@ -1042,7 +1070,7 @@ export function emitArrIntrinsic(
// removes to the end (+Infinity, the slice convention).
const start = host.emitExpr(e.args[0]!);
const cnt = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, cnt, f64Lit(0));
host.declare(`declare ptr @scr_arr_splice(ptr, double, double)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_arr_splice(ptr ${r.name}, double ${start.name}, double ${cnt})`);
@@ -1052,7 +1080,12 @@ export function emitArrIntrinsic(
const start = host.emitExpr(e.args[0]!);
const count = host.emitExpr(e.args[1]!);
const items = emitBorrowedInput(host, e.args[2]!);
host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
if (host.publishedTypes?.containerGuarded(e.receiver.type) === true) {
host.declare(`declare double @scr_arr_len(ptr)`);
const n = B.tmp();
B.line(`${n} = call double @scr_arr_len(ptr ${items.name})`);
host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, count.name, n);
}
host.declare(`declare ptr @scr_arr_splice_insert(ptr, double, double, ptr)`);
const t = B.tmp();
B.line(
@@ -46,4 +46,7 @@ export interface LlStreamTypedRefAdapter {
recordOps?: string;
/** `@sym` of the ScrDynTypedClassOps table for class instance capsules. */
classOps?: string;
/** `@sym` of the ScrDynTypedBagClassOps table for capsules of bag-tracked
* class instances whose views inherit from prototype data. */
bagClassOps?: string;
}
@@ -18,6 +18,7 @@ import {
type IrExpr,
type IrType,
isDynTypedRefType,
textCodecRecordClass,
isRefCounted,
isUnitType,
typeEquals,
@@ -86,7 +87,10 @@ export function emitDynamicExpr(
// Bytes already box their shared mutable storage directly. A second
// capsule would split identity between checked and native views.
const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className);
if (!errorRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
// A TextEncoder or TextDecoder record boxes as its own handle, which
// keeps the record's identity (toDynHelper).
const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null;
if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
if (v.type.kind === "union") {
const adapter = host.liveDynUnionRefAdapter(v.type);
const boxed = B.tmp();
@@ -1076,9 +1076,10 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayState on non-array");
const proven = host.provenDenseIndex(e.arr, e.index);
const t = host.checkedInt32Array(e.arr)
? host.emitByElementKind(arr.name, "double", (kind) =>
emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind)),
emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind), proven),
)!
: emitDenseArrayState(
host,
@@ -1087,6 +1088,7 @@ export function emitContainerExpr(
e.index,
"state",
host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem),
proven,
);
return { name: t, type: e.type };
}
@@ -7,6 +7,7 @@ import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { f64Lit, llvmCommentText } from "./common.js";
import { borrowableInputs, emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js";
import { regexLiteralTemplate } from "./expr-primitives.js";
import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
import { preservesRegexInputs } from "./reference-effects.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
@@ -365,6 +366,114 @@ export function emitRecordKeyGet(
return host.own({ name: t, type: e.type });
}
/** A keyed read whose result is a scalar union (`r[k]` on a
* `Record<string, number>`, at `number | undefined`) and whose every hit
* is a number or boolean arm of it, or an inline field of the same union.
* Projections (`??`, `!`, tag tests) read it as a private stack box, so
* the read allocates nothing. */
export function scalarRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "recordKeyGet" || e.missNaN === true || e.type.kind !== "union") return false;
const resultType = e.type;
const def = host.unionsById.get(resultType.unionId);
if (!isScalarUnionDef(def) || undefinedArmTag(resultType, host.unionsById) < 0) return false;
const isArm = (t: IrType): boolean =>
(t.kind === "f64" || t.kind === "bool") && def!.arms.some((a) => typeEquals(a, t));
const shape = host.recordShape(e.shapeId);
if (e.overflowOnly !== true) {
for (const f of shape.fields) {
const inline = host.recordFieldInline(e.shapeId, f.name);
if (!isArm(f.type) && !(inline && typeEquals(f.type, resultType))) return false;
}
}
return shape.indexValue === undefined || isArm(shape.indexValue);
}
/** A scalarRecordKeyGet read as a private stack box: the tag and bits of
* the declared field or overflow entry the key names, or the undefined arm
* for a miss. The box must never reach an RC entry point. */
export function emitStackRecordKeyGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
): string {
const B = host.B;
if (e.type.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: stack keyed read of a non-union");
const resultType = e.type;
const def = host.unionsById.get(resultType.unionId)!;
const armTag = (t: IrType): number => def.arms.findIndex((a) => typeEquals(a, t));
const obj = host.emitExpr(e.obj);
const key = host.emitExpr(e.key);
const box = B.slot();
B.entryAllocas.push(`${box} = alloca %ScrUnion`);
const tagPtr = B.tmp(),
bitsPtr = B.tmp();
B.line(`store ${host.sizeType} -1, ptr ${box}`);
B.line(`${tagPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 1`);
B.line(`${bitsPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 5`);
const join = B.newLabel("rks.j");
const storeScalar = (t: IrType, value: string): void => {
const bits = B.tmp();
if (t.kind === "f64") B.line(`${bits} = bitcast double ${value} to i64`);
else B.line(`${bits} = zext i1 ${value} to i64`);
B.line(`store i32 ${armTag(t)}, ptr ${tagPtr}`);
B.line(`store i64 ${bits}, ptr ${bitsPtr}`);
};
const shape = host.recordShape(e.shapeId);
for (const f of e.overflowOnly === true ? [] : shape.fields) {
const hit = B.tmp();
B.line(
`${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`,
);
const lh = B.newLabel("rks.h");
const ln = B.newLabel("rks.n");
B.condBr(hit, lh, ln);
B.startBlock(lh);
const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, f.name);
if (inline) {
const { tag, bits } = loadInlineUnion(B, ptr);
const present = presentBits(B, tag, bits, undefinedArmTag(type, host.unionsById));
B.line(`store i32 ${tag}, ptr ${tagPtr}`);
B.line(`store i64 ${present}, ptr ${bitsPtr}`);
} else {
storeScalar(type, host.loadRecordField(ptr, type, false));
}
B.br(join);
B.startBlock(ln);
}
const iv = shape.indexValue;
if (iv) {
const ovf = host.recordOvfPtr(obj.name, e.shapeId);
const outTy = iv.kind === "f64" ? "double" : "i8";
const outSlot = B.slot();
B.entryAllocas.push(`${outSlot} = alloca ${outTy}`);
B.line(`store ${outTy} ${iv.kind === "f64" ? f64Lit(0) : "0"}, ptr ${outSlot}`);
const getter = `scr_map_get_str_${iv.kind === "f64" ? "f64" : "bool"}`;
host.declare(`declare zeroext i1 @${getter}(ptr, ptr, ptr)`);
const found = B.tmp();
B.line(`${found} = call zeroext i1 @${getter}(ptr ${ovf}, ptr ${key.name}, ptr ${outSlot})`);
const lh = B.newLabel("rks.h");
const ln = B.newLabel("rks.n");
B.condBr(found, lh, ln);
B.startBlock(lh);
const raw = B.tmp();
B.line(`${raw} = load ${outTy}, ptr ${outSlot}`);
let value = raw;
if (iv.kind === "bool") {
value = B.tmp();
B.line(`${value} = trunc i8 ${raw} to i1`);
}
storeScalar(iv, value);
B.br(join);
B.startBlock(ln);
}
// A miss is the undefined arm.
B.line(`store i32 ${undefinedArmTag(resultType, host.unionsById)}, ptr ${tagPtr}`);
B.line(`store i64 0, ptr ${bitsPtr}`);
B.br(join);
B.startBlock(join);
return box;
}
export function keyedRecordReadInto(
host: LlvmEmitterContext,
slot: string,
@@ -16,6 +16,7 @@ import {
KEY_ORDER_FIELD,
shapeKeyOrders,
isDynTypedRefType,
textCodecRecordClass,
isRefCounted,
shapeHasAccessorSlots,
typeKey,
@@ -597,12 +598,21 @@ export function liveDynUnionRefAdapter(
if (!union) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of unknown union ${t.unionId}`);
}
// Codec records box as their own handles, by reference, through toDynHelper.
const codecArm = (arm: IrType): boolean =>
textCodecRecordClass(arm, (id) => host.recordsById.get(id)) !== null;
const mutableArms = union.arms
.map((arm, tag) => ({ arm, tag }))
.filter(
({ arm }) => arm.kind !== "bytes" && (streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
({ arm }) =>
arm.kind !== "bytes" &&
!codecArm(arm) &&
(streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
);
if (mutableArms.length === 0 && !union.arms.some((arm) => arm.kind === "bytes")) {
if (
mutableArms.length === 0 &&
!union.arms.some((arm) => arm.kind === "bytes" || codecArm(arm))
) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of immutable union ${key}`);
}
@@ -712,7 +722,13 @@ export function streamTypedRefBoxValue(
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${value})`);
return boxed;
}
if (t.kind === "bytes" || (!streamTypedRefEligible(t) && !isDynTypedRefType(t))) {
// A codec record boxes as its own handle, by reference (toDynHelper), as
// it does outside a live view.
if (
t.kind === "bytes" ||
textCodecRecordClass(t, (id) => host.recordsById.get(id)) !== null ||
(!streamTypedRefEligible(t) && !isDynTypedRefType(t))
) {
const valueTy = host.llType(t);
B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(t)}(${valueTy} ${value})`);
return boxed;
@@ -767,6 +783,7 @@ export function streamTypedRefMaterializeAdapter(
// class view ops are published first.
const opsMeta = isDynTypedRefType(t) ? host.classMeta.get(t.className) : undefined;
if (opsMeta && liveClassOpsEligible(opsMeta)) adapter.classOps = `@${snapshot}_cops`;
else if (opsMeta && liveBagClassOpsEligible(opsMeta)) adapter.bagClassOps = `@${snapshot}_bops`;
adapter.commit = `@${snapshot}_commit`;
adapter.commit = host.streamTypedRefCommitAdapter(t, snapshot);
const B = new BlockBuilder();
@@ -779,6 +796,7 @@ export function streamTypedRefMaterializeAdapter(
);
}
if (adapter.classOps !== undefined) liveClassOps(host, snapshot, meta);
if (adapter.bagClassOps !== undefined) liveBagClassOps(host, snapshot, meta, t);
if (meta.hierarchy && meta.children.length) {
const pre = B.tmp();
for (const line of classPreLines(meta, classStructSym(t.className), "%p", pre, host.sizeType))
@@ -850,11 +868,7 @@ export function streamTypedRefMaterializeAdapter(
B.line(`call void @scr_dyn_release_v(ptr ${prototype})`);
B.line(`call void @scr_dyn_release_v(ptr ${attached})`);
}
for (const field of meta.def.fields.filter(
(f) =>
isClassOwnEnumerableFieldName(f.name) &&
!(meta.root.def.name === "%Error" && (f.name === "message" || f.name === "name")),
)) {
for (const field of classViewFields(meta)) {
const afterField = meta.def.tracksOwnFields ? B.newLabel("field.after") : null;
if (afterField) {
host.declare(`declare ptr @scr_dyn_obj_get(ptr, ptr, ${host.sizeType})`);
@@ -868,32 +882,7 @@ export function streamTypedRefMaterializeAdapter(
B.condBr(present, yes, afterField);
B.startBlock(yes);
}
const { index, compressed } = classFieldIndex(meta, field.name, "compressed");
const fieldPtr = B.tmp();
let fieldValue = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`,
);
if (host.int32Slots.isField(t.className, field.name)) {
const raw = B.tmp();
B.line(`${raw} = load i32, ptr ${fieldPtr}`);
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (host.inlineUnions.classField(t.className, field.name))
fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else if (compressed) {
const idx = B.tmp();
B.line(`${idx} = load i32, ptr ${fieldPtr}`);
for (const line of compactRefLoadLines(idx, fieldValue, `${fieldValue}.r`))
B.line(line.trimStart());
} else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
if (llFieldType(field.type, false) === "i8") {
const boolValue = B.tmp();
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
const { boxed, nullable, fieldValue } = classFieldDynValue(host, B, meta, t, field);
// Attributes copied from the bag do not stop the view taking the value.
const setter = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set";
if (withAttributes)
@@ -1491,14 +1480,157 @@ function liveClassOps(host: LlvmEmitterContext, snapshot: string, meta: LlClassM
);
}
/** The checked-dynamic value a class instance's view holds for `field`, read
* from its native slot of `%p`: `boxed` (+1), and the nullable union
* `fieldValue` the caller releases when `nullable` is set. */
function classFieldDynValue(
host: LlvmEmitterContext,
B: BlockBuilder,
meta: LlClassMeta,
t: Extract<IrType, { kind: "object" }>,
field: LlClassMeta["def"]["fields"][number],
): { boxed: string; nullable: NullableRefField | null; fieldValue: string } {
const { index, compressed } = classFieldIndex(meta, field.name, "compressed");
const fieldPtr = B.tmp();
let fieldValue = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`,
);
if (host.int32Slots.isField(t.className, field.name)) {
const raw = B.tmp();
B.line(`${raw} = load i32, ptr ${fieldPtr}`);
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (host.inlineUnions.classField(t.className, field.name))
fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else if (compressed) {
const idx = B.tmp();
B.line(`${idx} = load i32, ptr ${fieldPtr}`);
for (const line of compactRefLoadLines(idx, fieldValue, `${fieldValue}.r`))
B.line(line.trimStart());
} else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
if (llFieldType(field.type, false) === "i8") {
const boolValue = B.tmp();
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
return { boxed: host.streamTypedRefBoxValue(B, field.type, fieldValue), nullable, fieldValue };
}
/** The fields an instance's view takes from their native slots, in order:
* its own enumerable fields, except an Error's message and name. */
function classViewFields(meta: LlClassMeta): LlClassMeta["def"]["fields"] {
return meta.def.fields.filter(
(f) =>
isClassOwnEnumerableFieldName(f.name) &&
!(meta.root.def.name === "%Error" && (f.name === "message" || f.name === "name")),
);
}
/** Classes whose instance views the materializer builds from the property
* bag and the prototype data alone (see liveBagClassOps): JavaScript classes
* tracking their own fields, outside the Error and runtime families, with no
* subclass whose view another materializer builds. */
function liveBagClassOpsEligible(meta: LlClassMeta): boolean {
return (
meta.def.tracksOwnFields === true &&
(meta.def.prototypeDataHelper !== undefined ||
meta.def.instancePrototypeHelper !== undefined) &&
!meta.def.runtime &&
!meta.root.def.runtime &&
meta.root.def.name !== "%Error" &&
!(meta.hierarchy && meta.children.length > 0) &&
meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) &&
!meta.def.fields.some(
(field) =>
field.name === CLASS_ATTRIBUTES ||
meta.def.fields.some((f) => f.name === classFieldAttributesName(field.name)),
)
);
}
/** The hooks of a bag-tracked class instance capsule
* (ScrDynTypedBagClassOps). The materializer gives the instance's view
* exactly the bag's string-keyed properties, the values of the declared
* fields among them from their native slots, and the prototype these hooks
* return, so a method call resolves without building the view. */
function liveBagClassOps(
host: LlvmEmitterContext,
snapshot: string,
meta: LlClassMeta,
t: Extract<IrType, { kind: "object" }>,
): void {
const struct = classStructSym(t.className);
const bag = new BlockBuilder();
const slot = bag.tmp(),
value = bag.tmp();
bag.line(
`${slot} = getelementptr inbounds %${struct}, ptr %p, i64 0, i32 ${classFieldIndex(meta, DYN_CLASS_PROPERTIES).index}`,
);
bag.line(`${value} = load ptr, ptr ${slot}`);
bag.terminate(`ret ptr ${value}`);
const proto = new BlockBuilder();
const prototype = proto.tmp();
if (meta.def.instancePrototypeHelper) {
const receiver = proto.tmp();
proto.line(`${receiver} = call ptr ${vAdapters(host.shapeHost, t).retain}(ptr %p)`);
proto.line(
`${prototype} = call ptr @${mangleFunction(meta.def.instancePrototypeHelper)}(ptr ${receiver})`,
);
} else proto.line(`${prototype} = call ptr @${mangleFunction(meta.def.prototypeDataHelper!)}()`);
proto.terminate(`ret ptr ${prototype}`);
// Field `%i` of the view's field list, as the materializer boxes it.
const fields = classViewFields(meta);
const get = new BlockBuilder();
const cases = fields.map((_, i) => get.newLabel(`field.${i}`));
const none = get.newLabel("field.none");
get.terminate(
`switch ${host.sizeType} %i, label %${none} [ ${cases.map((label, i) => `${host.sizeType} ${i}, label %${label}`).join(" ")} ]`,
);
fields.forEach((field, i) => {
get.startBlock(cases[i]!);
const { boxed, nullable, fieldValue } = classFieldDynValue(host, get, meta, t, field);
if (nullable) {
host.declare(`declare void @scr_union_release(ptr)`);
get.line(`call void @scr_union_release(ptr ${fieldValue})`);
}
get.terminate(`ret ptr ${boxed}`);
});
get.startBlock(none);
get.terminate(`ret ptr null`);
const names = fields.map((field) => `ptr ${host.internLiteral(field.name)}`);
host.resolveThunkDefs.push(
`define internal ptr @${snapshot}_field(ptr %p, ${host.sizeType} %i) ${FN_ATTRS} { ; view field of ${meta.def.name}`,
get.render(),
`}`,
``,
`@${snapshot}_fnames = internal constant [${names.length} x ptr] ${names.length ? `[${names.join(", ")}]` : "zeroinitializer"}`,
);
host.resolveThunkDefs.push(
`define internal ptr @${snapshot}_bag(ptr %p) ${FN_ATTRS} { ; property bag of ${meta.def.name}`,
bag.render(),
`}`,
``,
`define internal ptr @${snapshot}_proto(ptr %p) ${FN_ATTRS} { ; view prototype of ${meta.def.name}`,
proto.render(),
`}`,
``,
`@${snapshot}_bops = internal constant { ptr, ptr, ${host.sizeType}, ptr, ptr } { ptr @${snapshot}_bag, ptr @${snapshot}_proto, ${host.sizeType} ${fields.length}, ptr @${snapshot}_fnames, ptr @${snapshot}_field }`,
``,
);
}
/** The live view a capsule of this adapter binds: the runtime bind call
* and its ops constant, with the view code a deferred receiver recipe
* records (1 array, 2 record, 3 class). */
* records (1 array, 2 record, 3 class, 4 bag-tracked class). */
export function liveRefView(
adapter: LlStreamTypedRefAdapter,
): { bind: string; ops: string; code: 1 | 2 | 3 } | null {
): { bind: string; ops: string; code: 1 | 2 | 3 | 4 } | null {
if (adapter.classOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_class", ops: adapter.classOps, code: 3 };
if (adapter.bagClassOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_bag_class", ops: adapter.bagClassOps, code: 4 };
if (adapter.recordOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_record", ops: adapter.recordOps, code: 2 };
if (adapter.arrayOps !== undefined)
@@ -727,6 +727,17 @@ export function streamCbThunkFor(
const ty = host.llType(p);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`);
// An entry `encoding` declared as a string (the declarations'
// `encoding: string`) receives "" where Node passes undefined or
// null (an object-mode write), as a write's encoding parameter does.
const entry =
p.kind === "array" && p.elem.kind === "record"
? host.recordsById.get(p.elem.shapeId)
: undefined;
if (entry?.fields.some((f) => f.name === "encoding" && f.type.kind === "string")) {
host.declare(`declare void @scr_stream_writev_fill_encodings(ptr)`);
d.push(` call void @scr_stream_writev_fill_encodings(ptr %chunks)`);
}
d.push(
` %vt${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %chunks, ptr null)`,
` %vtbad${i} = call zeroext i1 @scr_exc_pending()`,
@@ -25,6 +25,9 @@ import { rawBytes } from "./lib-abi.js";
import { emitInlineAtomicsOp } from "./expr-bytes.js";
import { regexLiteralCaptures } from "./regex-matchers.js";
/** ScrDyn.v.typed_ref.type_key_len: the low 27 bits of its bit-field word. */
const TYPE_KEY_LEN_MASK = (1 << 27) - 1;
export function emitAssertInspectLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "assert.shapeStr" || e.fn === "assert.shapeRe") {
@@ -290,6 +293,38 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
B.line(`store i1 true, ptr ${slot}`);
B.condBr(same, done, compare);
B.startBlock(compare);
// A capsule with another key pointer usually belongs to another
// class: reject it on its key length and last eight bytes before
// the out-of-line name comparison, so a class table's chain of
// probes costs a few instructions per class it skips.
B.line(`store i1 false, ptr ${slot}`);
const bytes = Buffer.from(literal, "utf8");
const lenSlot = B.tmp(),
lenWord = B.tmp(),
len = B.tmp(),
lenEq = B.tmp();
B.line(
`${lenSlot} = getelementptr inbounds i8, ptr ${value.name}, i64 ${host.abiOffset(88, 52)} ; ->v.typed_ref.type_key_len`,
);
B.line(`${lenWord} = load i32, ptr ${lenSlot}`);
B.line(`${len} = and i32 ${lenWord}, ${TYPE_KEY_LEN_MASK}`);
B.line(`${lenEq} = icmp eq i32 ${len}, ${bytes.length}`);
const full = B.newLabel("brand.full");
if (bytes.length >= 8) {
const tail = B.newLabel("brand.tail");
B.condBr(lenEq, tail, done);
B.startBlock(tail);
const tailPtr = B.tmp(),
word = B.tmp(),
wordEq = B.tmp();
B.line(`${tailPtr} = getelementptr inbounds i8, ptr ${keyPtr}, i64 ${bytes.length - 8}`);
B.line(`${word} = load i64, ptr ${tailPtr}, align 1`);
B.line(`${wordEq} = icmp eq i64 ${word}, ${bytes.readBigInt64LE(bytes.length - 8)}`);
B.condBr(wordEq, full, done);
} else {
B.condBr(lenEq, full, done);
}
B.startBlock(full);
}
}
if (e.fn === "dyn.typedRefIs") {
@@ -306,6 +341,41 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
B.line(`${result} = load i1, ptr ${slot}`);
return { name: result, type: e.type };
}
if (e.fn === "dyn.classIs") {
// Only a class constructor's function value can match. Expose the
// function tag to LLVM so a chain of constructor probes (a static
// property bag's dispatch) costs one test for every other value, and
// borrow the receiver across a literal key as the capsule probes do.
const literal = e.args[1]!.kind === "strLit";
const value = literal ? host.emitReadReceiver(e.args[0]!) : host.emitExpr(e.args[0]!);
const key = host.emitExpr(e.args[1]!);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca i1`);
B.line(`store i1 false, ptr ${slot}`);
const present = B.tmp();
const inspect = B.newLabel("ctor.inspect");
const compare = B.newLabel("ctor.compare");
const done = B.newLabel("ctor.done");
B.line(`${present} = icmp ne ptr ${value.name}, null`);
B.condBr(present, inspect, done);
B.startBlock(inspect);
const kind = host.dynKind(value.name);
const fn = B.tmp();
B.line(`${fn} = icmp eq i32 ${kind}, ${DYN_KIND.FUNC}`);
B.condBr(fn, compare, done);
B.startBlock(compare);
host.declare("declare zeroext i1 @scr_dyn_class_is_key(ptr, ptr)");
const matches = B.tmp();
B.line(
`${matches} = call zeroext i1 @scr_dyn_class_is_key(ptr ${value.name}, ptr ${key.name})`,
);
B.line(`store i1 ${matches}, ptr ${slot}`);
B.br(done);
B.startBlock(done);
const result = B.tmp();
B.line(`${result} = load i1, ptr ${slot}`);
return { name: result, type: e.type };
}
if (e.fn === "crypto.randomFillCb" || e.fn === "crypto.randomFillCbDyn") {
host.usesTimers = true;
const args = e.args.map((arg) => host.emitExpr(arg));
@@ -779,6 +849,19 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return { name: t, type: e.type };
}
if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type });
if (e.fn === "record.isFrozen") {
// The frozen bit of the record's rc word (scr_rc.h SCR_RC_FROZEN) is
// its second-highest bit; an immortal word sets the highest one too.
const value = host.emitReadReceiver(e.args[0]!);
const S = host.sizeType;
const rc = host.B.tmp(),
top = host.B.tmp(),
t = host.B.tmp();
host.B.line(`${rc} = load ${S}, ptr ${value.name}`);
host.B.line(`${top} = lshr ${S} ${rc}, ${S === "i64" ? 62 : 30}`);
host.B.line(`${t} = icmp eq ${S} ${top}, 1`);
return { name: t, type: e.type };
}
const prefix = e.fn.slice(0, e.fn.indexOf(".")) as LibCallPrefix;
// events.once builds typed listener adapters beside the emitter calls.
if (e.fn === "events.once" || e.fn === "events.onceSignal")
@@ -887,6 +970,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "blob":
case "wasm":
case "array":
case "collection":
case "record":
case "disposable":
case "punycode":
@@ -560,6 +560,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"text.decoderEncoding": "scr_text_decoder_encoding",
"text.decoderName": "scr_text_decoder_name",
"text.encodeInto": "scr_text_encode_into",
"text.codecIs": "scr_text_codec_is",
"buffer.isAscii": "scr_buffer_is_ascii",
"buffer.isUtf8": "scr_buffer_is_utf8",
"buffer.transcode": "scr_buffer_transcode",
@@ -585,6 +586,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"crypto.ecdhConvertKey": "scr_crypto_ecdh_convert_key",
"crypto.dhNew": "scr_crypto_dh_new",
"crypto.checkPrimeSync": "scr_crypto_check_prime_sync",
"crypto.getCipherInfo": "scr_crypto_get_cipher_info",
"crypto.checkPrimeCb": "scr_crypto_check_prime_async",
"crypto.generatePrimeSync": "scr_crypto_generate_prime_sync",
"crypto.generatePrimeCb": "scr_crypto_generate_prime_async",
@@ -897,6 +899,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// ambient-this read never throw. The fs dyn read is the sync-fs story.
"fetch.start": "scr_fetch_static",
"fetch.responseNew": "scr_fetch_response_new",
"fetch.responseLight": "scr_fetch_response_light",
"fetch.responseJsonStatic": "scr_fetch_response_json_static",
"fetch.input": "scr_fetch_input",
"abort.controllerNew": "scr_fetch_abort_controller_new",
@@ -957,6 +960,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fetch.streamIs": "scr_fetch_stream_is",
"zlib.streamIs": "scr_zlib_stream_is",
"fetch.streamNew": "scr_fetch_stream_new",
"fetch.transformNew": "scr_fetch_transform_new",
"fetch.streamFrom": "scr_fetch_stream_from",
"fetch.readerRead": "scr_fetch_reader_read",
"json.parse": "scr_json_parse",
@@ -1018,6 +1022,12 @@ export const LIB_FN_SYMS: Record<string, string> = {
"class.descriptorHasValue": "scr_class_descriptor_has_value",
"class.throwReadOnly": "scr_class_throw_read_only",
"class.throwNotExtensible": "scr_class_throw_not_extensible",
"record.freeze": "scr_record_freeze",
"record.throwFrozen": "scr_record_throw_frozen",
"array.freeze": "scr_arr_freeze",
"array.isFrozen": "scr_arr_is_frozen",
"collection.preventExtensions": "scr_map_prevent_extensions",
"collection.isExtensible": "scr_map_is_extensible",
"dyn.nativeSetNew": "scr_dyn_native_set_new",
"dyn.nativeSetIs": "scr_dyn_native_set_is",
"dyn.nativeMapIs": "scr_dyn_native_map_is",
@@ -1139,6 +1149,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"readable.pushEncoding": "scr_stream_set_push_encoding",
"stream.objectMode": "scr_stream_object_mode",
"stream.decodeStrings": "scr_stream_decode_strings",
"stream.optionValues": "scr_stream_option_values",
"stream.checkEncodingOption": "scr_stream_check_encoding_option",
"stream.duplexFromGen": "scr_stream_duplex_from_gen",
"stream.duplexFromDyn": "scr_stream_duplex_from_dyn",
"stream.duplexifyReadable": "scr_stream_duplexify_readable",
@@ -1323,6 +1335,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"http.reqPipeClient": "scr_http_req_pipe_client",
"http.reqPipeSock": "scr_http_req_pipe_sock",
"http.reqResume": "scr_http_req_resume",
"http.reqPause": "scr_http_req_pause",
"http.reqDestroy": "scr_http_req_destroy",
"http.reqSetEncoding": "scr_http_req_set_encoding",
"http.resSetHeader": "scr_http_res_set_header",
@@ -1687,6 +1700,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"fetch.abortAny",
"fetch.streamNew",
"fetch.streamFrom",
"fetch.transformNew",
"readable.new",
// fs.ReadStream / fs.WriteStream open, read and write on the worker pool.
"fs.createReadStream",
@@ -1772,6 +1786,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"stream.setTransform",
"stream.setFlush",
"stream.setConstruct",
"stream.replaceConstruct",
"stream.duplexFromGen",
"stream.duplexFromDyn",
"stream.duplexifyReadable",
@@ -189,6 +189,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
e.fn === "stream.setTransform" ||
e.fn === "stream.setFlush" ||
e.fn === "stream.setConstruct" ||
e.fn === "stream.replaceConstruct" ||
e.fn === "stream.setWritev" ||
e.fn === "stream.setWritevDefault"
) {
@@ -203,6 +204,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
"stream.setTransform": "t",
"stream.setFlush": "l",
"stream.setConstruct": "c",
"stream.replaceConstruct": "c",
"stream.setWritev": "v",
"stream.setWritevDefault": "v",
};
@@ -214,6 +216,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
"stream.setTransform": "scr_stream_set_transform",
"stream.setFlush": "scr_stream_set_flush",
"stream.setConstruct": "scr_stream_set_construct",
"stream.replaceConstruct": "scr_stream_replace_construct",
"stream.setWritev": "scr_stream_set_writev",
"stream.setWritevDefault": "scr_stream_set_writev_default",
};
@@ -797,7 +797,33 @@ export function emitLocalArrayRead(
);
return null;
});
else if (inline)
else if (
inline &&
loopIndex !== null &&
read.element.kind === "f64" &&
host.provenDenseIndex(read.array, read.index)
) {
// In a dense array loop's fast copy, the entry test proved the counter
// below the array's hole-free prefix: the slot holds a present value,
// and the read has no missing arm.
const dataPtr = B.tmp(),
data = B.tmp(),
valuePtr = B.tmp(),
value = B.tmp(),
width = int32 ? "i32" : "i64";
B.line(
`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
);
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(
`${valuePtr} = getelementptr inbounds ${width}, ptr ${data}, ${host.sizeType} ${loopIndex}`,
);
host.markMemoryPointer(valuePtr, "array:elements");
B.line(`${value} = load ${width}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
storeValue(value);
B.br(join);
} else if (inline)
emitDenseReferenceArrayRead(
host,
array,
@@ -6,6 +6,7 @@ import {
type IrUnionDef,
} from "../../ir/ir.js";
import { everyModuleNode } from "../../ir/traverse.js";
import type { IrNodeIndex } from "../../ir/node-index.js";
/** A union of exactly one reference arm and one or two unit arms
* (`C | undefined`, `C | null`, `C | null | undefined`) whose VALUES are
@@ -120,12 +121,12 @@ export class NullableUnions {
/** `shapes`: allow shape unions (debug builds keep boxes, whose
* debug-info layouts describe every arm). */
constructor(mod: IrModule, shapes = true) {
constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex) {
const emitted = new Set<string>();
for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name);
const records = new Set<string>();
for (const shape of mod.records ?? []) records.add(shape.id);
const boxed = boxedUnionIds(mod);
const boxed = boxedUnionIds(mod, nodes);
// Shape unions stay out of library builds (their host ABI marshals
// boxes) and of programs with lifetime regions (whose walks report a
// reference slot by its type's release entry point).
@@ -277,13 +278,20 @@ function shapeUnion(
/** Unions the runtime itself inspects as tagged boxes keep that
* representation: Map keys and Set elements hash and compare union keys by
* tag and payload inside the C map. */
function boxedUnionIds(mod: IrModule): Set<string> {
function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const boxed = new Set<string>();
// Only the module's union definitions consult the result.
if (!mod.unions?.length) return boxed;
const mark = (t: IrType): void => {
if (t.kind === "union") boxed.add(t.unionId);
};
if (nodes !== undefined) {
for (const t of nodes.types) {
if (t.kind === "map") mark(t.key);
else if (t.kind === "set") mark(t.elem);
}
return boxed;
}
everyModuleNode(mod, {
expr: () => true,
stmt: () => true,
@@ -18,6 +18,7 @@ import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js";
/** Read a receiver whose value is consumed immediately by a field or tag
@@ -95,6 +96,8 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null;
if (narrowed) return narrowed;
if (canStackUnion(e, emitter.boxedUnionsById)) return emitStackUnion(emitter, e).value;
if (e.kind === "recordKeyGet" && scalarRecordKeyGet(emitter, e))
return { name: emitStackRecordKeyGet(emitter, e), type: e.type };
if (e.kind === "unionNarrow") {
const union = emitter.emitUnionProjection(e.value);
emitUnionNarrowGuard(emitter, union.name, e.unionId, e.tag);
@@ -342,6 +345,7 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void {
borrowsWithoutOwning: (e: IrExpr) => emitter.borrowsWithoutOwning(e),
borrowedReturn: (callee: string) => emitter.borrowedReturns.has(callee),
parameterAllowed: (id: string) => allowed.has(id),
nodes: emitter.nodeIndex,
};
return emitter.referenceEffects.functions.has(fn.name)
? host
@@ -409,6 +413,7 @@ export function isStackUnionSource(emitter: LlEmitter, e: IrExpr): boolean {
emitter.optionalArrayReads.get(e) !== null ||
isScalarOptionalSource(e, emitter.unionSources) ||
emitter.scalarResultCall(e) ||
emitter.inlineFieldGet(e)
emitter.inlineFieldGet(e) ||
scalarRecordKeyGet(emitter, e)
);
}
@@ -9,6 +9,7 @@ import {
type IrUnionDef,
} from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js";
import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js";
import { borrowsStringInputs } from "./string-lifetimes.js";
@@ -227,6 +228,8 @@ export class ReferenceWrites {
/** Fields no code writes once their object is constructed
* (immutability.ts, from cycle-mutability's construction-only facts). */
private readonly immutable: ImmutableFields | null = null,
/** The module's node index, read instead of walking the bodies. */
private readonly nodes?: IrNodeIndex,
) {
this.key(HEAP_KEY);
this.unions = new Map((types.unions ?? []).map((u) => [u.id, u]));
@@ -236,25 +239,25 @@ export class ReferenceWrites {
const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`));
this.constructors = constructors;
this.freshConstructors = [...functions.values()].every((fn) =>
fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)),
this.onlySuperCalls(fn, constructors),
);
this.callTargets = new CallTargets(functions, this.virtualTargets);
this.callTargets = new CallTargets(functions, this.virtualTargets, nodes);
this.solve(constructors);
}
/** Every direct call of a constructor is a `super` call from a
/** Every direct call of a constructor in `fn` is a `super` call from a
* constructor on its own `this`. */
private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet<string>): boolean {
private onlySuperCalls(fn: IrFunction, constructors: ReadonlySet<string>): boolean {
const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined;
const expr = (e: IrExpr): boolean => {
if (e.kind === "call" && constructors.has(e.callee)) {
const self = e.args[0];
if (thisId === undefined || !self || !isThisRef(self, thisId)) return false;
let only = true;
eachNodeOfKinds(fn, this.nodes?.scan(CALL_KINDS), (node) => {
if (node.kind === "call" && constructors.has(node.callee)) {
const self = node.args[0];
if (thisId === undefined || !self || !isThisRef(self, thisId)) only = false;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt);
return stmt(s);
return only;
});
return only;
}
private key(k: string): number {
@@ -299,7 +302,7 @@ export class ReferenceWrites {
}
const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null;
const boxed = boxedLocals(fn);
const privates = privateArrays(fn, boxed);
const privates = privateArrays(fn, boxed, this.nodes);
const sink: WriteSink = {
add: (k) => {
// Interned for queries; a callee's locals are private to it.
@@ -749,7 +752,7 @@ export class ReferenceWrites {
enter(fn: IrFunction): void {
this.current = fn;
this.currentBoxed = boxedLocals(fn);
this.currentPrivateArrays = privateArrays(fn, this.currentBoxed);
this.currentPrivateArrays = privateArrays(fn, this.currentBoxed, this.nodes);
this.currentLocals = new Set(fn.locals.map((local) => local.id));
this.inConstructor = this.constructors.has(fn.name);
for (const p of fn.params) this.currentLocals.add(p.localId);
@@ -973,6 +976,29 @@ function privateReceiver(arr: IrExpr, privates: ReadonlySet<string>): boolean {
* element out nor let the array escape. */
const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]);
/** The nodes the scans below read. */
const CALL_KINDS: readonly IrNodeKind[] = ["call"];
const CALL_SITE_KINDS: readonly IrNodeKind[] = ["call", "new", "virtualCall", "closure"];
const DEFINITION_KINDS: readonly IrNodeKind[] = ["assignExpr", "incDec", "varDecl", "assign"];
/** The nodes privateArrays reads. */
const PRIVATE_ARRAY_KINDS: readonly IrNodeKind[] = [
"varRef",
"assignExpr",
"incDec",
"closure",
"classRef",
"arrIntrinsic",
"arrayHas",
"varDecl",
"assign",
"arraySet",
"arraySetUndefined",
"arrayDelete",
"arraySetLength",
"return",
];
/** Array locals whose storage the function allocated itself and that
* nothing else can reach before the function returns them: declared once
* from an array literal or `new Array(n)`, never rebound, unboxed, and
@@ -985,7 +1011,11 @@ const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]);
* stay out of the function's summary (a caller's borrows predate the
* array). The literal map, filter and fused-pipeline helpers fill their
* results this way. */
function privateArrays(fn: IrFunction, boxed: ReadonlySet<string>): ReadonlySet<string> {
function privateArrays(
fn: IrFunction,
boxed: ReadonlySet<string>,
nodes: IrNodeIndex | undefined,
): ReadonlySet<string> {
if (fn.async || fn.generator) return new Set();
const candidates = new Set<string>();
for (const local of fn.locals)
@@ -1001,54 +1031,50 @@ function privateArrays(fn: IrFunction, boxed: ReadonlySet<string>): ReadonlySet<
if (e.kind === "varRef" && candidates.has(e.localId))
permitted.set(e, (permitted.get(e) ?? 0) + 1);
};
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
eachNodeOfKinds(fn, nodes?.scan(PRIVATE_ARRAY_KINDS), (node) => {
switch (node.kind) {
case "varRef": {
const left = permitted.get(e) ?? 0;
if (left === 0) rejected.add(e.localId);
else permitted.set(e, left - 1);
const left = permitted.get(node) ?? 0;
if (left === 0) rejected.add(node.localId);
else permitted.set(node, left - 1);
break;
}
case "assignExpr":
case "incDec":
rejected.add(e.localId);
rejected.add(node.localId);
break;
case "closure":
case "classRef":
for (const id of e.captures ?? []) rejected.add(id);
for (const id of node.captures ?? []) rejected.add(id);
break;
case "arrIntrinsic":
if (PRIVATE_ARRAY_METHODS.has(e.method)) permit(e.receiver);
if (PRIVATE_ARRAY_METHODS.has(node.method)) permit(node.receiver);
break;
case "arrayHas":
permit(e.arr);
permit(node.arr);
break;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
declarations.set(s.localId, (declarations.get(s.localId) ?? 0) + 1);
if (s.init === null || (s.init.kind !== "arrayLit" && s.init.kind !== "arrayNewLen"))
rejected.add(s.localId);
declarations.set(node.localId, (declarations.get(node.localId) ?? 0) + 1);
if (
node.init === null ||
(node.init.kind !== "arrayLit" && node.init.kind !== "arrayNewLen")
)
rejected.add(node.localId);
break;
case "assign":
rejected.add(s.localId);
rejected.add(node.localId);
break;
case "arraySet":
case "arraySetUndefined":
case "arrayDelete":
case "arraySetLength":
permit(s.arr);
permit(node.arr);
break;
case "return":
if (s.value !== null) permit(s.value);
if (node.value !== null) permit(node.value);
break;
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
});
const out = new Set<string>();
for (const id of candidates) if (!rejected.has(id) && declarations.get(id) === 1) out.add(id);
return out;
@@ -1081,30 +1107,30 @@ class CallTargets {
constructor(
private readonly functions: ReadonlyMap<string, IrFunction>,
virtualTargets: VirtualTargets,
private readonly nodes: IrNodeIndex | undefined,
) {
const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => {
let list = this.sites.get(callee);
if (!list) this.sites.set(callee, (list = []));
list.push({ caller, args });
};
const scan = nodes?.scan(CALL_SITE_KINDS);
for (const fn of functions.values()) {
if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name);
const expr = (e: IrExpr): boolean => {
if (e.kind === "call") site(e.callee, fn, e.args);
else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]);
else if (e.kind === "virtualCall") {
const targets = virtualTargets.targets(e.className, e.method);
for (const t of targets ?? []) site(t, fn, e.args);
} else if (e.kind === "closure") {
this.opaque.add(e.fnName);
let list = this.creators.get(e.fnName);
if (!list) this.creators.set(e.fnName, (list = []));
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") site(node.callee, fn, node.args);
else if (node.kind === "new")
site(`%${node.className}.constructor`, fn, [node, ...node.args]);
else if (node.kind === "virtualCall") {
const targets = virtualTargets.targets(node.className, node.method);
for (const t of targets ?? []) site(t, fn, node.args);
} else if (node.kind === "closure") {
this.opaque.add(node.fnName);
let list = this.creators.get(node.fnName);
if (!list) this.creators.set(node.fnName, (list = []));
list.push(fn);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt);
fn.body.every(stmt);
});
}
// Fixpoint over function-typed parameter positions.
const positions: [IrFunction, number][] = [];
@@ -1225,18 +1251,13 @@ class CallTargets {
defs = new Map();
const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id));
const counts = new Map<string, number>();
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2);
return everyExprChild(e, expr, stmt);
};
const stmt = (st: IrStmt): boolean => {
if (st.kind === "varDecl") {
counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1);
defs!.set(st.localId, st.init);
} else if (st.kind === "assign") counts.set(st.localId, 2);
return everyStmtChild(st, expr, stmt);
};
fn.body.every(stmt);
eachNodeOfKinds(fn, this.nodes?.scan(DEFINITION_KINDS), (node) => {
if (node.kind === "assignExpr" || node.kind === "incDec") counts.set(node.localId, 2);
else if (node.kind === "varDecl") {
counts.set(node.localId, (counts.get(node.localId) ?? 0) + 1);
defs!.set(node.localId, node.init);
} else if (node.kind === "assign") counts.set(node.localId, 2);
});
for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null);
this.definitions.set(fn, defs);
}
@@ -14,6 +14,7 @@ import {
type ShapeHost,
vAdapters,
} from "./shapes.js";
import { mangleRecordRelease } from "../mangle.js";
import { NullableUnions } from "./nullable-unions.js";
import { InlineUnionFields } from "./inline-unions.js";
@@ -315,6 +316,42 @@ describe("emitted object releases", () => {
root.indexOf("call void @scr_cyc_on_release(ptr %o)"),
);
});
test("freezable records count their references without the frozen bit", () => {
const mod: IrModule = {
irVersion: 17,
sourceFile: "frozen.ts",
entry: "main",
functions: [],
records: [
{ id: "plain", fields: [{ name: "n", type: F64 }] },
{ id: "frozen", fields: [{ name: "n", type: F64 }], freezable: true },
],
};
const body = (defs: string, name: string): string =>
new RegExp(`^define internal void @${name}\\([^]*?^}`, "m").exec(defs)?.[0] ?? "";
for (const [pointerBits, notFrozen, sharedBelow] of [
[64, "-4611686018427387905", "-4611686018427387907"],
[32, "-1073741825", "-1073741827"],
] as const) {
const { host } = declarationHost(pointerBits);
const defs = emitRecordShapes({ ...host, objectAudit: false }, mod).defs.join("\n");
const S = pointerBits === 64 ? "i64" : "i32";
const plain = body(defs, mangleRecordRelease("plain"));
expect(plain).toContain(`%shared = icmp ult ${S} %rcm2, -3`);
expect(plain).not.toContain("%refs");
// The immortal word's count is ~FROZEN, which the compare sends to the
// immortal test, like counts 0 and 1.
const fast = body(defs, mangleRecordRelease("frozen"));
expect(fast).toContain(`%refs = and ${S} %rc, ${notFrozen}`);
expect(fast).toContain(`%rcm2 = sub ${S} %refs, 2`);
expect(fast).toContain(`%shared = icmp ult ${S} %rcm2, ${sharedBelow}`);
expect(fast).toContain(`%imm = icmp eq ${S} %rc, -1`);
const slow = body(defs, `${mangleRecordRelease("frozen")}_slow`);
expect(slow).toContain(`%refs = and ${S} %n, ${notFrozen}`);
expect(slow).toContain(`%dead = icmp eq ${S} %refs, 0`);
}
});
});
describe("live-object audit notes", () => {
+249 -28
View File
@@ -34,6 +34,7 @@ import {
cycTypeDescriptor,
emitObjectAlloc,
emitObjectFree,
emitSegmentFree,
CYC_APPEND,
cycleAppendDecls,
cycleAppendHelper,
@@ -1405,17 +1406,23 @@ export interface DirectDestroy {
/** The release of child `value` (a loaded field, possibly NULL) inside a
* teardown whose chain length is `%d`: the child's own release for a shared
* or immortal reference, its teardown for the last one. Labels use `tag`;
* the lines end in the open block `${tag}.next`. */
* the lines end in the open block `${tag}.next`.
*
* With `segment` (the child has the teardown's own type, in a segmented
* teardown; segmentPrologueLines), the child's teardown appends its blocks
* to the teardown's segment. */
export function directChildReleaseLines(
host: ShapeHost,
tag: string,
value: string,
target: DirectDestroy,
comment: string,
segment = false,
): string[] {
const S = host.sizeType;
const C = target.count ?? S;
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
if (segment) host.declare(`declare ptr @scr_rc_destroy_seg(ptr, ptr, ptr, ptr)`);
else host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const lines = [
` %${tag}.nn = icmp ne ptr ${value}, null`,
` br i1 %${tag}.nn, label %${tag}.chk, label %${tag}.next`,
@@ -1447,16 +1454,139 @@ export function directChildReleaseLines(
` br i1 %${tag}.short, label %${tag}.direct, label %${tag}.bounded`,
`${tag}.direct:`,
` %${tag}.d = add i32 %d, 1`,
` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
);
if (!segment) {
lines.push(
` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
` br label %${tag}.next`,
`${tag}.bounded:`,
` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
` br label %${tag}.next`,
`${tag}.next:`,
);
return lines;
}
lines.push(
` %${tag}.t = load ptr, ptr %sg.t`,
` %${tag}.dt = call ptr @${target.destroy}_s(ptr ${value}, i32 %${tag}.d, ptr %${tag}.t)`,
` store ptr %${tag}.dt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.bounded:`,
` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
` %${tag}.bt = load ptr, ptr %sg.t`,
` %${tag}.rt = call ptr @scr_rc_destroy_seg(ptr ${value}, ptr @${target.destroy}_sc, ptr @${target.destroy}, ptr %${tag}.bt)`,
` store ptr %${tag}.rt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.next:`,
);
return lines;
}
/* Teardown segments. A typed teardown that frees each block onto its free
* list as it goes frees a tree in walk order, and the free lists, which are
* LIFO, hand the blocks to the next allocations in the reverse order: a tree
* built again in the same order as the last (each generation of a structure
* a loop rebuilds) is laid out mirrored, and every walk over it, the next
* teardown included, runs through memory backwards, which defeats the
* hardware prefetchers. The runtime frees large dead sets of the cycle
* collector in address order for the same reason (scr_mem_order_for_free).
*
* So the teardown of a type with children of its own type (the `_s` form,
* segmentedTeardown) keeps the blocks it frees in a segment, linked through
* their first words, which it builds in order through a tail pointer: the
* `_s` form takes the segment's last link (the first word of its last block,
* or the head slot of an empty segment) and returns the new one. A node
* first releases its own block (emitObjectFree, keeping it), then appends it
* ahead of its children's blocks when its address is the lower, behind them
* otherwise (segmentTeardownLines), and the children append theirs in field
* order, so a structure
* built parent first (object literals allocate before their fields) or
* children first (constructor arguments) comes back in ascending order. The
* chain's first teardown (`_d`) frees the whole segment onto the class's
* free list in one step (emitSegmentFree), and the walks over the next
* generation run forward. A teardown past DIRECT_DESTROY_CHAIN appends
* through scr_rc_destroy_seg, under the same depth budget as
* scr_rc_destroy. Blocks outside the calling thread's arena are freed as
* before; one type's blocks are all of one class. */
/** Whether the teardown of a type whose reference fields have `fieldTypes`
* (with direct targets `targets`) and whose own teardown is `destroy` keeps
* a segment: a child field has its own type, and the inline allocation paths
* are in use. */
export function segmentedTeardown(
host: ShapeHost,
destroy: string,
fieldTypes: readonly IrType[],
targets: ReadonlyMap<string, DirectDestroy>,
): boolean {
if (host.inlineAlloc !== true) return false;
return fieldTypes.some((t) => directDestroyTarget(host, t, targets)?.destroy === destroy);
}
/** The first lines of a segmented teardown, in its entry block: the
* segment's last link (`%sg.t`, from the `%tail` argument) and the kept own
* block (`%sg.own`, for emitObjectFree). */
export function segmentPrologueLines(): string[] {
return [
` %sg.t = alloca ptr`,
` %sg.own = alloca ptr`,
` store ptr %tail, ptr %sg.t`,
` store ptr null, ptr %sg.own`,
];
}
/** The rest of a segmented teardown after emitObjectFree kept (or freed)
* its own block: the block goes ahead of its children's when its address is
* below the first non-NULL one of `children` (loaded fields of the
* teardown's own type, which stand for their blocks) or there is none, and
* behind them otherwise; then the children's releases (`releases`, given a
* label prefix), and the segment's last link is returned. Each order has its
* own copy of the releases, so neither keeps the decision, or the block once
* it is appended, across the children's teardowns. */
export function segmentTeardownLines(
children: readonly string[],
releases: (prefix: string) => string[],
): string[] {
const lines = [` %sg.o = load ptr, ptr %sg.own`, ` %sg.kept = icmp ne ptr %sg.o, null`];
let ref = "null";
[...children].reverse().forEach((child, i) => {
lines.push(
` %sg.nn${i} = icmp ne ptr ${child}, null`,
` %sg.r${i} = select i1 %sg.nn${i}, ptr ${child}, ptr ${ref}`,
);
ref = `%sg.r${i}`;
});
lines.push(
` %sg.leaf = icmp eq ptr ${ref}, null`,
` %sg.lower = icmp ult ptr %sg.o, ${ref}`,
` %sg.first = or i1 %sg.leaf, %sg.lower`,
` %sg.ahead = and i1 %sg.kept, %sg.first`,
` br i1 %sg.ahead, label %sg.front, label %sg.back`,
`sg.front:`,
...segmentAppendLines("a"),
...releases("ta"),
` br label %sg.ret`,
`sg.back:`,
...releases("tb"),
` %sg.keep = icmp ne ptr %sg.o, null`,
` br i1 %sg.keep, label %sg.last, label %sg.ret`,
`sg.last:`,
...segmentAppendLines("l"),
` br label %sg.ret`,
`sg.ret:`,
` %sg.tr = load ptr, ptr %sg.t`,
` ret ptr %sg.tr`,
);
return lines;
}
function segmentAppendLines(tag: string): string[] {
return [
` %sg.t${tag} = load ptr, ptr %sg.t`,
` store ptr %sg.o, ptr %sg.t${tag}`,
` store ptr %sg.o, ptr %sg.t`,
];
}
/** The direct-teardown target of a reference of `type`, if `targets` (keyed
* `record:<id>` / `object:<class>`) has one. Nullable unions hold the arm's
* pointer, NULL or an immortal sentinel, which the child release handles. */
@@ -1499,12 +1629,42 @@ export function usedDirectTargets(
/** The teardown definitions for `destroy`: with `chained`, the body takes
* the chain length `%d` (directChildReleaseLines) and the runtime's
* one-argument entry starts a chain. */
* one-argument entry starts a chain. With `segment` (the host, for a
* segmented teardown), the body is a segment teardown's
* (segmentPrologueLines to segmentTeardownLines), defined as `_s`, with
* `_sc` for scr_rc_destroy_seg, and `_d` starts a segment and frees it. */
export function destroyDefinitions(
destroy: string,
body: readonly string[],
chained: boolean,
segment: ShapeHost | null = null,
): string[] {
if (segment !== null)
return [
`define internal ptr @${destroy}_s(ptr %o, i32 %d, ptr %tail) ${FN_ATTRS} {`,
`entry:`,
...body,
`}`,
`define internal ptr @${destroy}_sc(ptr %o, ptr %tail) ${FN_ATTRS} {`,
`entry:`,
` %t = call ptr @${destroy}_s(ptr %o, i32 0, ptr %tail)`,
` ret ptr %t`,
`}`,
`define internal void @${destroy}_d(ptr %o, i32 %d) ${FN_ATTRS} {`,
`entry:`,
` %head = alloca ptr`,
` store ptr null, ptr %head`,
` %last = call ptr @${destroy}_s(ptr %o, i32 %d, ptr %head)`,
` %first = load ptr, ptr %head`,
...emitSegmentFree(segment, "sgf", "%first", "%last"),
` ret void`,
`}`,
`define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
`entry:`,
` call void @${destroy}_d(ptr %o, i32 0)`,
` ret void`,
`}`,
];
if (!chained)
return [
`define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
@@ -1527,6 +1687,17 @@ export function destroyDefinitions(
];
}
/** The frozen bit of a freezable record's rc word (scr_rc.h SCR_RC_FROZEN):
* bit 62 on 64-bit targets, bit 30 on 32-bit ones. */
function recordFrozenBit(sizeType: RcCount): bigint {
return 1n << (sizeType === "i64" ? 62n : 30n);
}
/** `value` as a signed LLVM integer literal of `sizeType`. */
function sizeLiteral(sizeType: RcCount, value: bigint): string {
return BigInt.asIntN(sizeType === "i64" ? 64 : 32, value).toString();
}
/** Common NULL/immortal/decrement skeleton for ordinary object releases.
* `freeBody` owns the zero-ref teardown and must leave the current block at
* its end; traced objects also get the possible-cycle-root branch.
@@ -1543,13 +1714,17 @@ export function releaseBody(
bounded = true,
immortal = false,
chained = false,
segmented = false,
frozen = false,
): string[] {
const S = host.sizeType;
if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const destroy = `${fnName}_destroy`;
// A freezable record's count is the word without its frozen bit.
const notFrozen = sizeLiteral(S, ~recordFrozenBit(S));
const lines = [
...(bounded ? destroyDefinitions(destroy, freeBody, chained) : []),
...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal),
...(bounded ? destroyDefinitions(destroy, freeBody, chained, segmented ? host : null) : []),
...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal, S, frozen),
`define internal void @${fnName}_slow(ptr %o) noinline ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`,
`entry:`,
` %isnull = icmp eq ptr %o, null`,
@@ -1561,7 +1736,9 @@ export function releaseBody(
`dec:`,
` %n = sub ${S} %rc, 1`,
` store ${S} %n, ptr %o`,
` %dead = icmp eq ${S} %n, 0`,
...(frozen
? [` %refs = and ${S} %n, ${notFrozen}`, ` %dead = icmp eq ${S} %refs, 0`]
: [` %dead = icmp eq ${S} %n, 0`]),
` br i1 %dead, label %free, label %${traced ? "root" : "done"}`,
`free:`,
...(traced ? cycleDeadLines(host, "teardown") : []),
@@ -1591,9 +1768,14 @@ export function releaseFastPath(
comment = "",
immortal = false,
count: RcCount = host.sizeType,
frozen = false,
): string[] {
const S = count;
if (immortal) return immortalRcBody(host, fnName, "void", comment);
// A freezable record compares its count without the frozen bit: an
// immortal word's count is then ~FROZEN, which the same compare sends to
// `slow`.
const notFrozen = ~recordFrozenBit(S);
return [
`define internal void @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (fast path)` : ""}`,
`entry:`,
@@ -1601,8 +1783,13 @@ export function releaseFastPath(
` br i1 %isnull, label %done, label %check`,
`check:`,
` %rc = load ${S}, ptr %o`,
` %rcm2 = sub ${S} %rc, 2`,
` %shared = icmp ult ${S} %rcm2, -3`,
...(frozen
? [
` %refs = and ${S} %rc, ${sizeLiteral(S, notFrozen)}`,
` %rcm2 = sub ${S} %refs, 2`,
` %shared = icmp ult ${S} %rcm2, ${sizeLiteral(S, notFrozen - 2n)}`,
]
: [` %rcm2 = sub ${S} %rc, 2`, ` %shared = icmp ult ${S} %rcm2, -3`]),
` br i1 %shared, label %dec, label %slow`,
`dec:`,
` %n = sub ${S} %rc, 1`,
@@ -1780,26 +1967,58 @@ export function emitRecordShapes(
const chained =
directTargets.has(`record:${shape.id}`) ||
refMembers.some((m) => directDestroyTarget(host, m.type, directTargets) !== null);
for (const m of refMembers) {
freeBody.push(
` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
` %v${t} = load ptr, ptr %f${t}`,
const destroy = `${mangleRecordRelease(shape.id)}_destroy`;
const bounded = refMembers.some((member) => needsBoundedRelease(member.type));
const segmented =
bounded &&
segmentedTeardown(
host,
destroy,
refMembers.map((m) => m.type),
directTargets,
);
const direct = directDestroyTarget(host, m.type, directTargets);
if (direct) {
freeBody.push(
...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(m.name)),
);
} else {
freeBody.push(
` call void ${releaseSym(host, m.type)}(ptr %v${t}) ; ${llvmCommentText(m.name)}`,
);
}
t++;
}
// Closed layouts can carry a kept record source (record-sources.ts).
const observers = recordMayKeepSource(shape) ? "weak-or-source" : "weak";
freeBody.push(...emitObjectFree(host, traced, observers));
// A segmented teardown loads its fields, releases its own block, and
// then releases the fields (segmentTeardownLines).
const loads: string[] = [];
const ownType: string[] = [];
const release = (prefix: string, m: (typeof refMembers)[number], i: number): string[] => {
const direct = directDestroyTarget(host, m.type, directTargets);
if (direct === null)
return [` call void ${releaseSym(host, m.type)}(ptr %v${i}) ; ${llvmCommentText(m.name)}`];
const own = segmented && direct.destroy === destroy;
return directChildReleaseLines(
host,
`${prefix}${i}`,
`%v${i}`,
direct,
llvmCommentText(m.name),
own,
);
};
for (const m of refMembers) {
const fieldLines = [
` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
` %v${t} = load ptr, ptr %f${t}`,
];
if (segmented) {
loads.push(...fieldLines);
if (directDestroyTarget(host, m.type, directTargets)?.destroy === destroy)
ownType.push(`%v${t}`);
} else freeBody.push(...fieldLines, ...release("td", m, t));
t++;
}
if (segmented)
freeBody.push(
...segmentPrologueLines(),
...loads,
...emitObjectFree(host, traced, observers, "%sg.own"),
...segmentTeardownLines(ownType, (prefix) =>
refMembers.flatMap((m, i) => release(prefix, m, i)),
),
);
else freeBody.push(...emitObjectFree(host, traced, observers));
defs.push(
...releaseBody(
host,
@@ -1807,9 +2026,11 @@ export function emitRecordShapes(
traced,
freeBody,
"",
refMembers.some((member) => needsBoundedRelease(member.type)),
bounded,
false,
chained,
segmented,
shape.freezable === true,
),
``,
);
@@ -44,8 +44,9 @@ export type StackObjectFieldOwner = (fields: readonly StackObjectField[]) => voi
* standalone scalar class whose constructor never lets `this` escape
* (record-results.ts). The caller proves that every use of the value only
* projects it. Record fields may own references; the frame releases them
* (stackObjectFields). Published shapes keep heap storage: their field
* guards treat the immortal header as a frozen object. */
* (stackObjectFields). Published and freezable shapes keep heap storage:
* their field guards treat the immortal header as a frozen object, and a
* frozen record keeps its frozen bit in a counted header. */
export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource {
// A ThreadSlots key keeps its slot index for its lifetime and has its
// values released when it is freed: it lives on the heap.
@@ -53,6 +54,8 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO
return false;
if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true)
return false;
if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true)
return false;
// A producer with a result-slot entry builds its fresh result in the
// caller's storage.
if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee));
@@ -1,5 +1,5 @@
import { isRefCounted, type IrExpr, type IrFunction, type IrLocal } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
/** What the emitter knows about a projection's storage. */
export interface WalkBorrowHost {
@@ -22,8 +22,24 @@ export interface WalkBorrowHost {
* (whose own rebinding releases nothing). Absent: the body preserves
* every edge. */
unwritten?(value: IrExpr, candidates: ReadonlySet<string>): boolean;
/** The module's node index, read instead of walking the body. */
nodes?: IrNodeIndex;
}
/** The node kinds analyzeWalks reads from a body. */
const WALK_KINDS: readonly IrNodeKind[] = [
"varDecl",
"assign",
"forOf",
"rethrow",
"tryCatch",
"return",
"assignExpr",
"incDec",
"closure",
"classRef",
];
export interface WalkFacts {
locals: ReadonlySet<string>;
/** Every return yields a walk (or a throw), so the body can return its
@@ -71,44 +87,36 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts {
if (!list) definitions.set(id, (list = []));
list.push(value);
};
everyStmtList(fn.body, {
stmt: (s) => {
switch (s.kind) {
case "varDecl":
define(s.localId, s.init);
break;
case "assign":
define(s.localId, s.value);
break;
case "forOf":
excluded.add(s.localId);
break;
case "rethrow":
excluded.add(s.localId);
break;
case "tryCatch":
guarded = true;
if (s.catchLocalId !== null) excluded.add(s.catchLocalId);
break;
case "return":
returns.push(s.value);
break;
}
return true;
},
expr: (e) => {
switch (e.kind) {
case "assignExpr":
case "incDec":
excluded.add(e.localId);
break;
case "closure":
case "classRef":
for (const id of e.captures ?? []) excluded.add(id);
break;
}
return true;
},
eachNodeOfKinds(fn, host.nodes?.scan(WALK_KINDS), (node) => {
switch (node.kind) {
case "varDecl":
define(node.localId, node.init);
break;
case "assign":
define(node.localId, node.value);
break;
case "forOf":
excluded.add(node.localId);
break;
case "rethrow":
excluded.add(node.localId);
break;
case "tryCatch":
guarded = true;
if (node.catchLocalId !== null) excluded.add(node.catchLocalId);
break;
case "return":
returns.push(node.value);
break;
case "assignExpr":
case "incDec":
excluded.add(node.localId);
break;
case "closure":
case "classRef":
for (const id of node.captures ?? []) excluded.add(id);
break;
}
});
const plain = (id: string): boolean => {
const local = locals.get(id);
+27 -11
View File
@@ -3,7 +3,8 @@
import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js";
import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js";
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex } from "../ir/node-index.js";
import { hasRetainedFfiCallback } from "./ffi-callbacks.js";
import { computePublishedTypes, writesPublished } from "./publication.js";
@@ -40,6 +41,8 @@ export function computeMayThrow(
explain?: boolean;
/** computePublishedTypes(mod), when the caller already has it. */
published?: ReturnType<typeof computePublishedTypes>;
/** The module's node index, read instead of walking the bodies. */
nodes?: IrNodeIndex;
} = {},
): {
fns: Set<string>;
@@ -52,8 +55,9 @@ export function computeMayThrow(
* entry (and may therefore throw the stack-overflow RangeError). */
stackChecks?: Set<string>;
} {
const terminations = mod.workers === true ? workerTerminationPolls(mod) : null;
const stackChecks = options.stackChecks === true ? stackCheckedFunctions(mod) : null;
const terminations = mod.workers === true ? workerTerminationPolls(mod, options.nodes) : null;
const stackChecks =
options.stackChecks === true ? stackCheckedFunctions(mod, options.nodes) : null;
// Stores that can reach a published object throw the frozen TypeError.
const published =
options.published !== undefined ? options.published : computePublishedTypes(mod);
@@ -898,7 +902,19 @@ function boundedNode(rec: IrExpr | IrStmt): boolean {
* finishes after finitely many steps or reaches a polling callee. `polls`
* names the functions that poll (and therefore may unwind with the
* termination sentinel); `entry` the subset that polls on entry. */
export function workerTerminationPolls(mod: IrModule): {
/** The kinds call-graph census below can skip: boundedNode answers true
* for them, and the census reads nothing else from them. */
const CENSUS_SKIPPED_KINDS: ReadonlySet<string> = new Set(
[...BOUNDED_KINDS].filter(
(kind) =>
!["while", "doWhile", "for", "forOf", "call", "new", "virtualCall", "closure"].includes(kind),
),
);
export function workerTerminationPolls(
mod: IrModule,
nodes?: IrNodeIndex,
): {
entry: Set<string>;
polls: Set<string>;
} {
@@ -914,11 +930,12 @@ export function workerTerminationPolls(mod: IrModule): {
const edges = new Map<string, string[]>();
const entry = new Set<string>();
const polls = new Set<string>();
const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS);
for (const fn of mod.functions) {
const out: string[] = [];
let bounded = fn.async !== true && fn.generator === undefined && fn.name !== mod.entry;
let loops = false;
const visit = (rec: IrExpr | IrStmt): boolean => {
const visit = (rec: IrExpr | IrStmt): void => {
if (!boundedNode(rec)) bounded = false;
switch (rec.kind) {
case "while":
@@ -937,9 +954,8 @@ export function workerTerminationPolls(mod: IrModule): {
out.push(...(methodImpls.get(rec.method) ?? []));
break;
}
return true;
};
everyStmtList(fn.body, { expr: visit, stmt: visit });
eachNodeOfKinds(fn, scan, visit);
edges.set(
fn.name,
out.filter((callee) => names.has(callee)),
@@ -960,7 +976,7 @@ export function workerTerminationPolls(mod: IrModule): {
* library call; see boundedNode) also reaches every function that such an
* operation can invoke: closure targets and class members. Non-recursive
* functions, including leaf callbacks, stay check-free. */
export function stackCheckedFunctions(mod: IrModule): Set<string> {
export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const reentry = "\0reentry";
const methodImpls = new Map<string, string[]>();
const memberPrefixes = new Set<string>();
@@ -975,12 +991,13 @@ export function stackCheckedFunctions(mod: IrModule): Set<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const edges = new Map<string, string[]>();
const targets = new Set<string>();
const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS);
for (const fn of mod.functions) {
const dot = fn.name.indexOf(".");
if (dot > 0 && memberPrefixes.has(fn.name.slice(0, dot + 1))) targets.add(fn.name);
const out: string[] = [];
let reenters = false;
const visit = (rec: IrExpr | IrStmt): boolean => {
const visit = (rec: IrExpr | IrStmt): void => {
if (!boundedNode(rec)) reenters = true;
switch (rec.kind) {
case "call":
@@ -996,9 +1013,8 @@ export function stackCheckedFunctions(mod: IrModule): Set<string> {
targets.add(rec.fnName);
break;
}
return true;
};
everyStmtList(fn.body, { expr: visit, stmt: visit });
eachNodeOfKinds(fn, scan, visit);
const callees = out.filter((callee) => names.has(callee));
if (reenters) callees.push(reentry);
edges.set(fn.name, callees);
@@ -81,6 +81,9 @@ export interface NativeLinkFeatures {
dnsPromises: boolean;
/** dgram sockets as untyped values: scr_dgram_dyn.c. */
dgramDyn: boolean;
/** TextEncoder and TextDecoder records as untyped values:
* scr_text_codec_dyn.c. */
textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -220,6 +220,10 @@ export interface CcOptions {
* scr_dgram_dyn.c over the dgram unit, so typed dgram binaries keep
* their exact link line. */
dgramDyn?: boolean;
/** A TextEncoder or TextDecoder record converts to or from an untyped
* value: compiles scr_text_codec_dyn.c, so typed codec binaries keep
* their exact link line. */
textCodecDyn?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
@@ -302,6 +302,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
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.textCodecDyn, "scr_text_codec_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"),
@@ -72,6 +72,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_itable.c",
"scr_slots.c",
"scr_alloc.c",
"scr_cipher_info.c",
] as const;
export function runtimeSrcDir(): string {
@@ -85,3 +85,34 @@ test("published types close over fields, containers and whole hierarchies", () =
computePublishedTypes({ ...withStores, functions: withStores.functions.slice(1) }),
).toBeNull();
});
test("freezable array types carry the array write guard without a publish", () => {
const numbers = arrayOf(F64);
const push: IrStmt = {
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: ref("a", numbers),
args: [{ kind: "numLit", value: 1, type: F64, loc }],
type: F64,
loc,
},
loc,
};
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [fn("main", []), fn("pushNumber", [push])],
freezableArrays: ["array<f64>"],
};
const guarded = computePublishedTypes(mod)!;
expect(guarded.containerGuarded(numbers)).toBe(true);
expect(guarded.arrayGuards(numbers)).toEqual({ published: false, frozen: true });
expect(guarded.containerGuarded(arrayOf(STRING))).toBe(false);
// A frozen array's push throws, so the function may throw.
expect(computeMayThrow(mod).fns.has("pushNumber")).toBe(true);
const { freezableArrays: _frozen, ...plain } = mod;
expect(computePublishedTypes(plain)).toBeNull();
});
+38 -16
View File
@@ -3,7 +3,7 @@
* emitter guards them. */
import type { IrExpr, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { typeKey } from "../ir/ir.js";
import { everyStmtList } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js";
import { computeConstructionFacts } from "./immutability.js";
/** Array methods that write their receiver (the guarded ones). */
@@ -46,6 +46,10 @@ export class PublishedTypes {
readonly records = new Set<string>();
/** typeKeys of array, map and set types. */
readonly containers = new Set<string>();
/** typeKeys of the array types the program may freeze
* (IrModule.freezableArrays): a frozen array refuses writes like a
* published one, so their stores carry the same guard. */
readonly frozenArrays = new Set<string>();
/** Constructor stores into a `this` nothing else can reach yet
* (immutability.ts): never a published target, so never guarded. */
privateStores: ReadonlySet<IrStmt> = new Set();
@@ -64,25 +68,42 @@ export class PublishedTypes {
}
containerGuarded(t: IrType): boolean {
return (
(t.kind === "array" || t.kind === "map" || t.kind === "set") &&
this.containers.has(typeKey(t))
);
if (t.kind !== "array" && t.kind !== "map" && t.kind !== "set") return false;
const key = typeKey(t);
return this.containers.has(key) || this.frozenArrays.has(key);
}
/** Whether an array of type `t` may be published, and whether it may be
* frozen: the two states its write guard tests. */
arrayGuards(t: IrType): { published: boolean; frozen: boolean } {
const key = typeKey(t);
return { published: this.containers.has(key), frozen: this.frozenArrays.has(key) };
}
}
export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
/** The nodes that publish a value: intrinsics. */
const PUBLISHING_KINDS: readonly IrNodeKind[] = ["intrinsic"];
export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): PublishedTypes | null {
const roots: IrType[] = [];
const visit = (e: IrExpr): boolean => {
// Values read from an InternTable were published by whichever thread
// stored them.
if (e.kind === "intrinsic" && (e.name === "threads.publish" || e.name === "itable.value"))
roots.push(e.type);
return true;
};
const scan = nodes?.scan(PUBLISHING_KINDS);
for (const fn of mod.functions)
everyStmtList(fn.body, { expr: visit, stmt: (_s: IrStmt) => true });
if (roots.length === 0) return null;
eachNodeOfKinds(fn, scan, (node) => {
// Values read from an InternTable were published by whichever thread
// stored them.
if (
node.kind === "intrinsic" &&
(node.name === "threads.publish" || node.name === "itable.value")
)
roots.push(node.type);
});
const frozenArrays = mod.freezableArrays ?? [];
if (roots.length === 0 && frozenArrays.length === 0) return null;
if (roots.length === 0) {
const frozen = new PublishedTypes();
for (const key of frozenArrays) frozen.frozenArrays.add(key);
return frozen;
}
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const children = new Map<string, string[]>();
@@ -95,7 +116,8 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const out = new PublishedTypes();
out.privateStores = computeConstructionFacts(mod).privateStores;
for (const key of frozenArrays) out.frozenArrays.add(key);
out.privateStores = computeConstructionFacts(mod, nodes).privateStores;
const seen = new Set<string>();
const stack = [...roots];
const addHierarchy = (className: string): void => {
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 36 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v36" as const;
export const RUNTIME_ABI_VERSION = 37 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v37" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 36, marker: "scr_runtime_abi_v36" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -118,6 +118,7 @@ const features = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -54,6 +54,7 @@ const BASE: NativeLinkFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -127,7 +128,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 36, marker: "scr_runtime_abi_v36" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
compiler: {
command: "clang",
identity: "fixture clang",
@@ -1,15 +1,32 @@
/* Whole-program stack-trace gate (see observesStacks). Pure function of the
* IR module; the emitter keeps a source frame in every function only when
* it answers true. */
import { exprFunctionReference, moduleFunctionReferences } from "../ir/function-references.js";
import {
exprFunctionReference,
FUNCTION_REFERENCE_KINDS,
moduleFunctionReferences,
} from "../ir/function-references.js";
import {
eachNodeOfKinds,
type IrKindScan,
type IrNodeIndex,
type IrNodeKind,
} from "../ir/node-index.js";
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { MAY_THROW_ARR_METHODS } from "../ir/ir.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { unreachableStatements } from "./may-throw.js";
import type { PublishedTypes } from "./publication.js";
import { writesPublished } from "./publication.js";
/** The nodes the scans below read: stack reads, the "stack" literal, call
* sites and function references, declarations. */
const STACK_READ_KINDS: readonly IrNodeKind[] = ["libCall", "dynKeyGet"];
const STRING_KINDS: readonly IrNodeKind[] = ["strLit"];
const CENSUS_KINDS: readonly IrNodeKind[] = ["call", ...FUNCTION_REFERENCE_KINDS];
const DECLARATION_KINDS: readonly IrNodeKind[] = ["varDecl"];
/** Lib calls that read a captured stack trace. */
const STACK_READS: ReadonlySet<string> = new Set(["error.stack"]);
@@ -40,10 +57,13 @@ class StackKeys {
// The module walks run on the first question that needs them: a program
// without a dynamic key read asks none.
constructor(private readonly mod: IrModule) {}
constructor(
private readonly mod: IrModule,
private readonly nodes: IrNodeIndex | undefined,
) {}
private spellsStack(): boolean {
return (this.spelled ??= spellsStack(this.mod));
return (this.spelled ??= spellsStack(this.mod, this.nodes));
}
private callCensus(): {
@@ -56,20 +76,18 @@ class StackKeys {
const named = new Set(moduleFunctionReferences(mod));
// Class methods, accessors and constructors are called through dispatch.
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
const scan = this.nodes?.scan(CENSUS_KINDS);
for (const fn of mod.functions) {
if (isClassMember(fn.name, classNames)) named.add(fn.name);
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
if (expr.kind === "call") {
const sites = calls.get(expr.callee) ?? [];
sites.push({ caller: fn, args: expr.args });
calls.set(expr.callee, sites);
}
const referenced = exprFunctionReference(expr);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") {
const sites = calls.get(node.callee) ?? [];
sites.push({ caller: fn, args: node.args });
calls.set(node.callee, sites);
} else if (node.kind === "closure" || node.kind === "orDefault") {
const referenced = exprFunctionReference(node);
if (referenced !== undefined) named.add(referenced);
return true;
},
}
});
}
return (this.census = { calls, named });
@@ -127,13 +145,9 @@ class StackKeys {
let inits = this.inits.get(fn);
if (inits === undefined) {
inits = new Map();
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "varDecl")
inits!.set(stmt.localId, inits!.has(stmt.localId) ? null : (stmt.init ?? null));
return true;
},
expr: () => true,
eachNodeOfKinds(fn, this.nodes?.scan(DECLARATION_KINDS), (node) => {
if (node.kind === "varDecl")
inits!.set(node.localId, inits!.has(node.localId) ? null : (node.init ?? null));
});
this.inits.set(fn, inits);
}
@@ -161,21 +175,25 @@ function readsStack(fn: IrFunction, expr: IrExpr, keys: StackKeys): boolean {
}
/** Whether the module contains the string literal "stack". */
function spellsStack(mod: IrModule): boolean {
return mod.functions.some(
(fn) =>
!everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !(expr.kind === "strLit" && expr.value === "stack"),
}),
);
function spellsStack(mod: IrModule, nodes: IrNodeIndex | undefined): boolean {
const scan = nodes?.scan(STRING_KINDS);
return mod.functions.some((fn) => {
let found = false;
eachNodeOfKinds(fn, scan, (node) => {
found = node.kind === "strLit" && node.value === "stack";
return !found;
});
return found;
});
}
function containsStackRead(fn: IrFunction, keys: StackKeys): boolean {
return !everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !readsStack(fn, expr, keys),
function containsStackRead(fn: IrFunction, keys: StackKeys, scan: IrKindScan | undefined): boolean {
let found = false;
eachNodeOfKinds(fn, scan, (node, statement) => {
found = !statement && readsStack(fn, node as IrExpr, keys);
return !found;
});
return found;
}
/** Visit every expression of `body` that execution can reach: statements
@@ -199,9 +217,10 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void {
* Flight server keep development-only stack parsing behind `throw` stubs,
* and those reads must not cost every call of the program a frame.
* Programs without any stack read answer false without the walk. */
export function observesStacks(mod: IrModule): boolean {
const keys = new StackKeys(mod);
if (!mod.functions.some((fn) => containsStackRead(fn, keys))) return false;
export function observesStacks(mod: IrModule, nodes?: IrNodeIndex): boolean {
const keys = new StackKeys(mod, nodes);
const reads = nodes?.scan(STACK_READ_KINDS);
if (!mod.functions.some((fn) => containsStackRead(fn, keys, reads))) return false;
const byName = new Map(mod.functions.map((fn) => [fn.name, fn]));
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
const pending: string[] = [...moduleFunctionReferences(mod)];
@@ -61,6 +61,7 @@ export async function warmNativeCaches(
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
+3 -3
View File
@@ -77,9 +77,9 @@ Options:
--npm-static <pkg[,pkg…]|auto>
compile the named npm packages' shipped JS statically as
program modules (repeatable; "auto" opts in every eligible
direct import: own .d.ts, unminified JS, no build-transform
markers; "pkg..." also names every package pkg depends
on). Packages that fail preflight are refused with a
direct import: own .d.ts, no build-transform markers;
"pkg..." also names every package pkg depends on).
Packages that fail preflight are refused with a
diagnostic. Eligible dependencies compile automatically.
--provenance-sources
EXPERIMENTAL: compile npm dependencies from their
@@ -900,7 +900,7 @@ export const NODE24_FETCH_COMPAT_PROFILE = {
{
name: "WritableStream/TransformStream",
reason:
"native support currently covers readable fetch bodies; general Web Streams graphs are unsupported",
"native support covers readable fetch bodies and the identity `new TransformStream()`; the WritableStream constructor, transformers, and general Web Streams graphs are unsupported",
},
{
name: "Readable byte/BYOB streams",
@@ -58,6 +58,7 @@ const native: EarlyExecutableNativeFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
@@ -92,6 +92,7 @@ export interface EarlyExecutableNativeFeatures {
dgram: boolean;
dnsPromises: boolean;
dgramDyn: boolean;
textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -235,6 +236,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgram",
"dnsPromises",
"dgramDyn",
"textCodecDyn",
"watch",
"foreignFfi",
"nodeTest",
@@ -252,6 +252,16 @@ class TypePrinter {
return props.length > 0 && props.every((p) => String(p.escapedName).startsWith("__@"));
}
/** Whether an interface type and every type it extends are declared in
* user files. */
private userInterface(type: ts.Type, seen = new Set<ts.Type>()): boolean {
if (!declaredInUserFile(type.getSymbol())) return false;
const target = type.isTypeReference() ? type.getTarget() : type;
if (target === undefined || !target.isClassOrInterface() || seen.has(target)) return true;
seen.add(target);
return this.checker.getBaseTypes(target).every((base) => this.userInterface(base, seen));
}
private printObject(type: ts.Type, depth: number): string | null {
const checker = this.checker;
if (checker.isArrayType(type)) {
@@ -280,8 +290,11 @@ class TypePrinter {
if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Function | ts.SymbolFlags.Method))
return null;
// Interfaces: only user-declared ones describe plain data reliably
// (package and lib interfaces often describe class instances).
if (symbol.flags & ts.SymbolFlags.Interface && !declaredInUserFile(symbol)) return null;
// (package and lib interfaces often describe class instances), and
// only when every interface they extend is user-declared too: a user
// interface extending a package one (effect's Request.Request)
// inherits members the package's own constructors create.
if (symbol.flags & ts.SymbolFlags.Interface && !this.userInterface(type)) return null;
}
if (
checker.getCallSignatures(type).length > 0 ||
@@ -15,6 +15,7 @@ import {
typeKey,
unionContainerArmsOk,
} from "../../ir/ir.js";
import { everyExpr } from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
import type * as ts from "../ts7/adapter.js";
@@ -193,6 +194,81 @@ export function unionArrayValueRead(
return result;
}
/** Expression kinds that evaluate without running other code or writing
* a binding. */
const PURE_KINDS = new Set<IrExpr["kind"]>([
"varRef",
"numLit",
"strLit",
"boolLit",
"unitLit",
"bin",
"unary",
"logical",
"ternary",
"toBool",
"fieldGet",
"recordGet",
"arrayGet",
"arrayState",
"arrayHas",
"unionIsTag",
"unionNarrow",
"unionWrap",
]);
/** The locals evaluating `value` may assign, and whether it may run other
* code (anything but the pure kinds and the read-only element helpers),
* which can assign locals and fields through closures. */
function valueEffects(lowerer: Lowerer, value: IrExpr): { writes: Set<string>; opaque: boolean } {
const readers = new Set<string>();
for (const [key, name] of lowerer.arrHofHelpers) if (key.startsWith("idxOr:")) readers.add(name);
const writes = new Set<string>();
let opaque = false;
everyExpr(value, {
expr: (e) => {
if (e.kind === "assignExpr" || e.kind === "incDec") writes.add(e.localId);
else if (e.kind === "call" ? !readers.has(e.callee) : !PURE_KINDS.has(e.kind)) opaque = true;
return true;
},
stmt: (s) => {
if (s.kind === "varDecl" || s.kind === "assign") writes.add(s.localId);
else opaque = true;
return true;
},
});
return { writes, opaque };
}
/** Whether evaluating `e` after `value` gives what it gives before it: a
* literal, or a local and field read `value` cannot change. */
function unaffectedBy(e: IrExpr, effects: { writes: Set<string>; opaque: boolean }): boolean {
switch (e.kind) {
case "numLit":
case "strLit":
return true;
case "varRef":
return !effects.writes.has(e.localId);
case "bin":
return unaffectedBy(e.left, effects) && unaffectedBy(e.right, effects);
case "fieldGet":
case "recordGet":
return !effects.opaque && unaffectedBy(e.obj, effects);
default:
return false;
}
}
/** Whether `e` reads a `let` or `var` binding, which code the value runs
* can assign through a closure. */
function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean {
return !everyExpr(e, {
expr: (x) =>
x.kind !== "varRef" || lowerer.ctx.locals.find((l) => l.id === x.localId)?.mutable === false,
stmt: () => true,
});
}
/** Store a stabilized value without changing the array's element ABI. */
export function arrayValueStore(
lowerer: Lowerer,
@@ -210,10 +286,31 @@ export function arrayValueStore(
// owned union temporary to release on every path.
const unionId = value.type.unionId;
let stable = value;
let prefix: IrStmt[] = [];
const prefix: IrStmt[] = [];
// The stores below evaluate the receiver and the index after the value.
// JavaScript evaluates them first (`out[n++] = src[n]` reads src[1]), so
// either may need its value held before the value runs.
if (value.kind !== "varRef") {
const valueFx = valueEffects(lowerer, value);
const hold = (e: IrExpr, name: string, effects: typeof valueFx): IrExpr => {
if (unaffectedBy(e, effects) && !(effects.opaque && readsMutableLocal(lowerer, e)))
return e;
const temp = lowerer.declareHiddenLocal(name, e.type);
prefix.push({ kind: "varDecl", localId: temp.id, init: e, loc });
return varRef(temp.id, temp.type, loc);
};
// The receiver runs before the index and the value, so the index's
// effects can change it too (`a[(a = b, 0)] = c[0]!`).
const indexFx = valueEffects(lowerer, index);
arr = hold(arr, "%arrayTarget", {
writes: new Set([...valueFx.writes, ...indexFx.writes]),
opaque: valueFx.opaque || indexFx.opaque,
});
index = hold(index, "%arrayIndex", valueFx);
}
if (value.kind !== "varRef") {
const temp = lowerer.declareHiddenLocal("%arrayValue", value.type);
prefix = [{ kind: "varDecl", localId: temp.id, init: value, loc }];
prefix.push({ kind: "varDecl", localId: temp.id, init: value, loc });
stable = varRef(temp.id, temp.type, loc);
}
const write: IrStmt = {
@@ -869,9 +869,17 @@ const IMPLEMENTED_CIPHERS = new Set([
"id-aes192-gcm",
"id-aes256-gcm",
"chacha20-poly1305",
...["128", "192", "256"].flatMap((bits) =>
["ecb", "cfb", "cfb8", "cfb1", "ofb"].map((mode) => `aes-${bits}-${mode}`),
),
...["128", "192", "256"].flatMap((bits) => [
...["ecb", "cfb", "cfb8", "cfb1", "ofb", "ccm"].map((mode) => `aes-${bits}-${mode}`),
`id-aes${bits}-ccm`,
...["wrap", "wrap-pad"].flatMap((wrap) => [
`aes-${bits}-${wrap}`,
`aes${bits}-${wrap}`,
`id-aes${bits}-${wrap}`,
`aes-${bits}-${wrap}-inv`,
`aes${bits}-${wrap}-inv`,
]),
]),
"des-ede3-cbc",
"des3",
"des-ede3",
@@ -889,9 +897,10 @@ const IMPLEMENTED_CIPHERS = new Set([
]);
const SUPPORTED_CIPHERS_HINT =
"the supported ciphers are aes-128, aes-192 and aes-256 in cbc, ctr, gcm, ecb, cfb, cfb8, cfb1 " +
"and ofb modes, des-ede3 in cbc, ecb, cfb, cfb8, cfb1 and ofb modes, des-ede in cbc, ecb, cfb " +
"and ofb modes, chacha20, and chacha20-poly1305";
"the supported ciphers are aes-128, aes-192 and aes-256 in cbc, ctr, gcm, ccm, ecb, cfb, cfb8, " +
"cfb1 and ofb modes and their wrap, wrap-pad, wrap-inv and wrap-pad-inv key wraps, des-ede3 in " +
"cbc, ecb, cfb, cfb8, cfb1 and ofb modes, des-ede in cbc, ecb, cfb and ofb modes, chacha20, and " +
"chacha20-poly1305";
/** A literal cipher name that Node knows but the runtime does not
* implement is refused at compile time. Unknown literal names keep Node's
@@ -1380,10 +1389,11 @@ function takesUndefinedError(lowerer: Lowerer, callback: ts.Expression): boolean
}
/** checkPrime, checkPrimeSync, generatePrime and generatePrimeSync
* (scr_dh.c): checked-dynamic arguments, which the runtime checks as Node
* does (a function in the options position is the callback), and the
* sync forms' boolean, ArrayBuffer or bigint converted to the call site's
* type. Null for other members. */
* (scr_dh.c), and getCipherInfo (scr_cipher_info.c): checked-dynamic
* arguments, which the runtime checks as Node does (a function in the
* options position is the callback), and the sync forms' boolean,
* ArrayBuffer, bigint or info object converted to the call site's type.
* Null for other members. */
function lowerPrimeCall(
lowerer: Lowerer,
expr: ts.CallExpression,
@@ -1395,6 +1405,7 @@ function lowerPrimeCall(
checkPrimeSync: ["crypto.checkPrimeSync", 1, 2, "checkPrimeSync(candidate[, options])"],
checkPrime: ["crypto.checkPrimeCb", 1, 3, "checkPrime(candidate[, options], callback)"],
generatePrimeSync: ["crypto.generatePrimeSync", 1, 2, "generatePrimeSync(size[, options])"],
getCipherInfo: ["crypto.getCipherInfo", 0, 2, "getCipherInfo(nameOrNid[, options])"],
generatePrime: ["crypto.generatePrimeCb", 1, 3, "generatePrime(size[, options], callback)"],
};
const form = forms[member];
@@ -1455,14 +1466,10 @@ function lowerPkiCall(
expr,
`${supported} is supported`,
);
// generateKeyPair(type, callback): Node reads a function in the options
// position as the callback.
const shifted = fn === "crypto.generateKeyPairCb" && args.length === 2;
// generateKeyPair(type, callback): the runtime reads a function in the
// options position as the callback, as Node does.
const values: IrExpr[] = [];
for (let i = 0; i < max; i++) {
const node = shifted ? (i === 1 ? undefined : i === 2 ? args[1] : args[i]) : args[i];
values.push(dynArg(lowerer, node, loc));
}
for (let i = 0; i < max; i++) values.push(dynArg(lowerer, args[i], loc));
if (fn === "crypto.generateKeyPairCb")
return { kind: "libCall", fn, args: values, type: VOID, loc };
const call: IrExpr = { kind: "libCall", fn, args: values, type: DYN, loc };
@@ -6,9 +6,11 @@ import {
DYN,
F64,
type IrExpr,
type IrType,
type IrStmt,
type SrcLoc,
STRING,
UNDEFINED_T,
VOID,
arrayOf,
BYTES_U8,
@@ -54,6 +56,21 @@ export function isReadlineTyped(lowerer: Lowerer, node: ts.Expression): boolean
});
}
/** `rl.cursor`: @types/node declares a number, but an interface that is
* not a terminal answers undefined, so the read keeps its
* `number | undefined` union. */
export function isOptionalReadlineCursorRead(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
): boolean {
return (
expr.name.text === "cursor" &&
!expr.questionDotToken &&
isReadlineTyped(lowerer, expr.expression) &&
lowerer.isStdlibMember(expr)
);
}
/** The runtime's listener event numbers (scr_readline.c). */
const RL_EVENTS: Record<string, number> = {
line: 0,
@@ -1341,7 +1358,7 @@ export function lowerReadlineCreateCall(
/** Property reads on an Interface: `history` (the history option itself,
* or the interface's own empty array), `historySize`, `historyIndex`,
* `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `terminal` and
* `cursor` (a non-terminal interface's undefined cursor throws SC2020).
* `cursor` (undefined on a non-terminal interface).
* Null for other receivers. */
export function lowerReadlineProperty(
lowerer: Lowerer,
@@ -1373,10 +1390,38 @@ export function lowerReadlineProperty(
loc,
};
}
const reads: Record<string, ["rl.line" | "rl.isTerminal" | "rl.cursor", IrExpr["type"]]> = {
if (name === "cursor") {
// `number | undefined`: a non-terminal interface has no cursor
const recv = lowerer.lowerExprExpecting(access.expression, F64);
const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, UNDEFINED_T]) };
const local = recv.kind === "varRef" ? null : lowerer.declareHiddenLocal("%rlCursor", F64);
const id: IrExpr = local ? { kind: "varRef", localId: local.id, type: F64, loc } : recv;
const result: IrExpr = {
kind: "ternary",
cond: { kind: "libCall", fn: "rl.isTerminal", args: [id], type: BOOL, loc },
then: lowerer.coerceToExpected(
{ kind: "libCall", fn: "rl.cursor", args: [id], type: F64, loc },
type,
),
else_: lowerer.coerceToExpected(
{ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
type,
),
type,
loc,
};
if (!local) return result;
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: local.id, init: recv, loc }],
result,
type,
loc,
};
}
const reads: Record<string, ["rl.line" | "rl.isTerminal", IrExpr["type"]]> = {
line: ["rl.line", STRING],
terminal: ["rl.isTerminal", BOOL],
cursor: ["rl.cursor", F64],
};
const read = reads[name];
if (read !== undefined) {
@@ -560,3 +560,53 @@ test("class branches read their exact class's bag, also for classes with subclas
then: [{ kind: "return", value: { kind: "dynObjLit", fields: [] } }],
});
});
test("records a class's bag initializer only when all its class-table branches agree", () => {
const { lowerer } = context();
const classBagInitializers = new Map<string, IrExpr>();
Object.assign(lowerer, { classBagInitializers });
for (const name of ["Material", "Texture"])
lowerer.classes.set(name, { def: { name, tracksOwnFields: true } } as unknown as ClassInfo);
const initializer = (
className: string,
prototype: string,
start: number,
): Extract<IrStmt, { kind: "fieldSet" }> => {
const at = { ...loc, start };
return {
kind: "fieldSet",
obj: {
kind: "dynCheck",
value: { kind: "varRef", localId: "p.0", type: DYN, loc: at },
type: { kind: "object", className },
loc: at,
},
className,
field: "%bag",
value: {
kind: "libCall",
fn: "dyn.objCreate",
args: [{ kind: "call", callee: prototype, args: [], type: DYN, loc: at }],
type: DYN,
loc: at,
},
loc: at,
};
};
const dispatch = new ClassDynamicDispatch();
const tables = (dispatch as unknown as { bagInitializers: Map<string, unknown[]> })
.bagInitializers;
// A base class whose own instances get its prototype and whose subclass
// instances get theirs through a per-instance dispatch has no single bag.
tables.set("Material", [
initializer("Material", "%prototype.data.Material", 1),
initializer("Material", "%class.bagPrototype:Material", 2),
]);
// Branches that differ only in source location agree.
tables.set("Texture", [
initializer("Texture", "%prototype.data.Texture", 3),
initializer("Texture", "%prototype.data.Texture", 4),
]);
dispatch.finalize(lowerer);
expect([...classBagInitializers.keys()]).toEqual(["Texture"]);
});
@@ -282,6 +282,11 @@ interface PropertyDispatch {
fn: IrFunction;
classes: Set<string>;
reflect?: boolean;
/** The helper's fallthrough statements as created, and the same access
* applied to the receiver itself: what the fallthrough reduces to for a
* value that is neither a class capsule nor a function, whose bag is the
* value. */
plain?: { tail: readonly IrStmt[]; access: IrStmt[] };
}
export function classInstanceOf(
@@ -982,6 +987,11 @@ function initializeBag(lowerer: Lowerer, className: string, self: IrExpr, loc: S
];
}
/** `value` as JSON without its source locations. */
function withoutLocations(value: IrExpr): string {
return JSON.stringify(value, (key, field: unknown) => (key === "loc" ? undefined : field));
}
/** Whether `value` reads local `id`. */
function mentionsLocal(value: IrExpr, id: string): boolean {
return JSON.stringify(value).includes(`"localId":${JSON.stringify(id)}`);
@@ -1502,6 +1512,7 @@ export class ClassDynamicDispatch {
});
changed = true;
}
let live: Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]> | null = null;
for (const plan of this.propertyReceivers(lowerer, false, false)) {
const info = plan.info;
const className = info.def.name;
@@ -1582,7 +1593,11 @@ export class ClassDynamicDispatch {
);
const prototypeFor = (current: ClassInfo, receiver: IrExpr, loc: SrcLoc): IrExpr | null =>
this.bagPrototype(lowerer, current, receiver, loc);
for (const existingInit of this.bagInitializers.get(className) ?? []) {
live ??= this.liveBagInitializers();
for (const existingInit of [
...(this.bagInitializers.get(className) ?? []),
...(live.get(className) ?? []),
]) {
if (existingInit.value.kind === "dynObjLit" && needsPrototype(info)) {
const prototype = prototypeFor(info, existingInit.obj, existingInit.loc);
if (prototype) {
@@ -2714,7 +2729,28 @@ export class ClassDynamicDispatch {
loc,
});
}
dispatch = { name, write: !!write, fn: helper, classes: new Set() };
const receiverAccess: IrStmt[] = read
? [{ kind: "return", value: { ...read, value: varRef("p.0", DYN, loc) }, loc }]
: [
{
kind: "exprStmt",
expr: {
...write!,
args: [varRef("p.0", DYN, loc), write!.args[1]!, varRef("p.1", DYN, loc)],
},
loc,
},
{ kind: "return", value: null, loc },
];
dispatch = {
name,
write: !!write,
fn: helper,
classes: new Set(),
...(name !== "constructor" && name !== "toString"
? { plain: { tail: [...helper.body], access: receiverAccess } }
: {}),
};
this.properties.set(key, dispatch);
this.generated.add(helper);
lowerer.liftedFns.push(helper);
@@ -4767,6 +4803,27 @@ export class ClassDynamicDispatch {
* A class with constructible subclasses that have prototype data selects
* it through its `%class.bagPrototype` helper; any other class reads its
* own prototype data. */
/** The lazy bag stores as they stand in the generated bodies, by class.
* Worklist passes that transform every function rebuild statement
* objects, so a recorded initializer can be a detached copy; a store that
* gains its prototype must be updated where it runs. */
private liveBagInitializers(): Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]> {
const live = new Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]>();
const add = (stmt: IrStmt | undefined): void => {
if (stmt?.kind !== "if") return;
const store = stmt.then[0];
if (store?.kind !== "fieldSet" || store.field !== PROPERTY_BAG) return;
const list = live.get(store.className);
if (list) list.push(store);
else live.set(store.className, [store]);
};
// The complete dispatch guards each class's lazy store by its receiver.
for (const stmt of this.propertyBag?.body ?? []) if (stmt.kind === "if") add(stmt.then[0]);
for (const helper of this.typedPropertyBags.values()) add(helper.body[0]);
for (const helper of this.exactPropertyBags.values()) add(helper.body[0]);
return live;
}
private bagPrototype(
lowerer: Lowerer,
current: ClassInfo,
@@ -4865,6 +4922,7 @@ export class ClassDynamicDispatch {
);
}
if (lowerer) this.guardClassTables(lowerer);
if (lowerer) this.guardPropertyHelpers(lowerer);
}
/** Each tracked class's bag initializer over a typed receiver (`this`),
@@ -4877,6 +4935,18 @@ export class ClassDynamicDispatch {
const self = varRef("this", { kind: "object", className }, init.loc);
const value = withTypedReceiver(structuredClone(init.value), self);
if (mentionsLocal(value, "p.0")) continue;
// The class-table branches can give the class's instances different
// bags (a base class's own prototype for its instances, the
// instance's class prototype for subclass instances); only a class
// whose branches all agree has one initializer for every instance.
const shape = withoutLocations(value);
if (
initializers.some(
(other) =>
withoutLocations(withTypedReceiver(structuredClone(other.value), self)) !== shape,
)
)
continue;
// Lowerer stand-ins in unit tests may not carry the table.
lowerer.classBagInitializers?.set(className, value);
}
@@ -4942,6 +5012,87 @@ export class ClassDynamicDispatch {
}
}
/** Fast exits for the generated property helpers: when every class
* branch tests a capsule key or a class key and the fallthrough is still
* the bag access the helper was created with, a value of another kind
* (a plain object, array or primitive) normalizes to itself, has itself
* as its bag, and so gets the plain access directly, without the
* normalization, bag and static-bag calls. */
private guardPropertyHelpers(lowerer: Lowerer): void {
// Every class table the fallthrough or normalization calls must answer
// a plain value with the value itself: its kind exit is in place.
const exits = (name: string): boolean => {
const helper = lowerer.liftedFnNamed(name);
const first = helper?.body[0];
const param = helper?.params[0]?.localId;
return (
param !== undefined &&
first?.kind === "if" &&
first.cond.kind === "dynTest" &&
first.cond.negated === true &&
first.then.length === 1 &&
first.then[0]!.kind === "return" &&
first.then[0]!.value !== null &&
returnsParam(first.then[0]!.value, param)
);
};
const tables = new Set([
"%dyn.class.properties",
READ_PROPERTIES,
READ_PROPERTIES_OR_EMPTY,
"%dyn.class.normalize",
"%dyn.class.staticProperties",
]);
const callsOnlyGuardedTables = (statements: IrStmt[]): boolean =>
everyStmtList(statements, {
stmt: () => true,
expr: (expression) =>
expression.kind !== "call" || (tables.has(expression.callee) && exits(expression.callee)),
});
for (const dispatch of this.properties.values()) {
const plain = dispatch.plain;
if (!plain) continue;
const fn = dispatch.fn;
const param = fn.params[0]?.localId;
if (param === undefined) continue;
const head = fn.body.length - plain.tail.length;
if (head < 0 || plain.tail.some((stmt, i) => fn.body[head + i] !== stmt)) continue;
let ok = true;
for (const stmt of fn.body.slice(0, head)) {
if (
stmt.kind === "assign" &&
stmt.localId === param &&
stmt.value.kind === "call" &&
stmt.value.callee === "%dyn.class.normalize"
)
continue;
if (stmt.kind === "if" && stmt.else_ === null && branchKind(stmt.cond, param) !== null)
continue;
ok = false;
break;
}
// Class branches never run for a plain value; the rest always does.
if (!ok || !callsOnlyGuardedTables(fn.body.filter((stmt) => stmt.kind !== "if"))) continue;
const loc = fn.loc;
const not = (test: "classCapsule" | "funcKind"): IrExpr => ({
kind: "dynTest",
test,
negated: true,
value: varRef(param, DYN, loc),
type: BOOL,
loc,
});
fn.body.unshift({
kind: "if",
cond: not("classCapsule"),
then: [{ kind: "if", cond: not("funcKind"), then: plain.access, else_: null, loc }],
else_: null,
loc,
});
delete dispatch.plain;
}
}
private normalizedDispatches(): IrFunction[] {
return [
...(this.propertyBag ? [this.propertyBag] : []),
@@ -2,6 +2,7 @@ import { keyedHelperName } from "../stable-names.js";
import * as ts from "../../ts7/adapter.js";
import {
BOOL,
DYN,
F64,
REGEX,
STRING,
@@ -14,7 +15,7 @@ import {
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { numLit, strLit, varRef } from "../../../ir/build.js";
import { dynUndefinedExpr, numLit, strLit, varRef } from "../../../ir/build.js";
import { InternalCompilerError } from "../../../errors.js";
import { locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
@@ -65,14 +66,14 @@ export function lowerStringReplacement(
const search = argument(args[0], false);
const replacement = argument(args[1], true);
if (search.type.kind === "dyn" || replacement.type.kind === "dyn") {
if (replacement.type.kind === "func") {
lowerer.noLowering(`string .${method} with checked search values and callbacks`, call);
}
// Evaluate all inputs once before choosing the search protocol. Regexes
// retain their native pattern; plain values use the string-search helper.
// A typed callback receives the regex match's arguments or the plain
// search's (match, offset, subject).
const values = [receiver, search, replacement];
const locals = values.map((value) => lowerer.declareHiddenLocal("%replaceArg", value.type));
const refs = locals.map((local) => varRef(local.id, local.type, loc));
const callback = replacement.type.kind === "func";
const checkedReplacement =
replacement.type.kind === "dyn"
? lowerer.coerceInto(args[1] ?? call, refs[2]!, STRING)
@@ -91,14 +92,27 @@ export function lowerStringReplacement(
? {
kind: "ternary",
cond: { kind: "libCall", fn: "dyn.nativeRegexIs", args: [refs[1]!], type: BOOL, loc },
then: {
kind: "regexIntrinsic",
method,
receiver: refs[0]!,
args: [lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX), checkedReplacement],
type: STRING,
loc,
},
then: callback
? {
kind: "libCall",
fn: "regex.replaceCallback",
args: [
refs[0]!,
lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX),
lowerer.coerceToExpected(refs[2]!, DYN),
{ kind: "boolLit", value: method === "replaceAll", type: BOOL, loc },
],
type: STRING,
loc,
}
: {
kind: "regexIntrinsic",
method,
receiver: refs[0]!,
args: [lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX), checkedReplacement],
type: STRING,
loc,
},
else_: plain,
type: STRING,
loc,
@@ -148,11 +162,17 @@ function lowerStringReplacementValues(
if (callback.kind !== "func") {
throw new InternalCompilerError(`string .${method} replacement of type ${callback.kind}`);
}
// A checked parameter receives the boxed argument (undefined after the
// subject), and a checked, number or boolean result converts with
// ToString.
const full = [STRING, F64, STRING];
const ret = callback.ret.kind;
if (
callback.ret.kind !== "string" ||
callback.params.length > full.length ||
!callback.params.every((type, index) => typeEquals(type, full[index]!))
(ret !== "string" && ret !== "dyn" && ret !== "f64" && ret !== "bool") ||
!callback.params.every(
(type, index) =>
type.kind === "dyn" || (index < full.length && typeEquals(type, full[index]!)),
)
) {
lowerer.noLowering(
`string .${method} callback signature`,
@@ -175,13 +195,21 @@ function lowerStringReplacementValues(
const chunksType = arrayOf(STRING);
const chunks = varRef("chunks.0", chunksType, loc);
const builder = stringBuilder(loc, chunks);
const value: IrExpr = {
const result: IrExpr = {
kind: "callValue",
callee: replace,
args: [needle, match, subject].slice(0, callback.params.length),
type: STRING,
args: callback.params.map((type, index) => {
const arg = [needle, match, subject][index];
if (arg === undefined) return dynUndefinedExpr(loc);
return type.kind === "dyn" ? lowerer.coerceToExpected(arg, DYN) : arg;
}),
type: callback.ret,
loc,
};
const value: IrExpr =
ret === "dyn"
? { kind: "libCall", fn: "dyn.toStringCoerce", args: [result], type: STRING, loc }
: lowerer.ensureString(result, call);
const stop: IrStmt = { kind: "break", loc };
const body: IrStmt[] = [
builder.declare("chunks.0", { kind: "arrayLit", elems: [], type: chunksType, loc }),
@@ -2841,6 +2841,24 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE
loc,
};
}
// A JavaScript default that tsc types `any` (`e.title || null` in an
// instance of a function with untyped parameters, where `e.title` is a
// string): the checked domain, as for union operands above.
if (
target === null &&
isJsSourceFile(ts.getSourceFileOfNode(expr)) &&
lowerer.dynConvertible(left.type) &&
lowerer.dynConvertible(right.type)
) {
return {
kind: "logical",
op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||",
left: lowerer.coerceInto(expr.left, left, DYN),
right: lowerer.coerceInto(expr.right, right, DYN),
type: DYN,
loc,
};
}
lowerer.unsupported("SC1042", expr);
}
return {
@@ -1028,7 +1028,7 @@ function lowerGuardedRecordWrite(
const value = stabilized(lowerer, lowerer.lowerExpr(expr.right), "%recordValue", receiver.stmts);
const write = lowerer.withExpressionOverride(target.expression, receiver.present, () =>
lowerer.withExpressionOverride(target.argumentExpression, key, () =>
lowerer.withExpressionOverride(expr.right, value, () => lowerElementWrite(lowerer, expr)),
lowerer.withExpressionOverride(expr.right, value, () => lowerer.lowerElementWrite(expr)),
),
);
return {
@@ -33,6 +33,7 @@ import {
isOptionalProcessVersionsRead,
} from "../builtins/process.js";
import { requestGeneralStreamSurface } from "../general-streams.js";
import { isOptionalReadlineCursorRead } from "../builtins/readline.js";
import { literalValues } from "../../literal-values.js";
import { literalUnionArm } from "../../union-discriminants.js";
import { checkedArmExtraction } from "../coercions/unions.js";
@@ -617,6 +618,8 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
if (ts.isPropertyAccessExpression(node) && isOptionalProcessStreamProperty(lowerer, node))
return expr;
if (isOptionalProcessVersionsRead(lowerer, node)) return expr;
if (ts.isPropertyAccessExpression(node) && isOptionalReadlineCursorRead(lowerer, node))
return expr;
// Represented fields and index values can include undefined even when
// checker inference or lib.d.ts declares strings (RegExp captures).
@@ -100,7 +100,9 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp
write = (value) => eventAccessorWrite(receiver, name, value, lowerer, loc);
} else if (ts.isPropertyAccessExpression(target)) {
const probed = tryLowerExpression(lowerer, target.expression);
if (probed?.type.kind === "dyn") {
// `value.#x ??= v` on a checked-dynamic value names the brand-checked
// field (fieldTarget), never a property called "#x".
if (probed?.type.kind === "dyn" && !ts.isPrivateIdentifier(target.name)) {
const receiver = save(probed, "%shortCircuitReceiver");
const key: IrExpr = { kind: "strLit", value: target.name.text, type: STRING, loc };
storage = DYN;
@@ -1499,6 +1499,25 @@ export function lowerPropertyAccessExpr(
) {
return afterReceiver(recvLowered, dynUndefinedExpr(loc), loc);
}
// A typed array under a checker `any` (a JS binding its own initializer's
// inference refers to: hono's etag digest `nextChunk`) reads through its
// box, as the method-call twin dispatches. The box shares the native
// storage, so it needs no fresh-result restriction.
if (
recvLowered.type.kind === "bytes" &&
(lowerer.typeOf(expr.expression).flags & ts.TypeFlags.Any) !== 0
) {
return lowerer.maybeNarrow(
{
kind: "dynKeyGet",
value: lowerer.coerceToExpected(recvLowered, DYN),
key: { kind: "strLit", value: expr.name.text, type: STRING, loc },
type: DYN,
loc,
},
expr,
);
}
lowerer.unsupported(
"SC1090",
expr,

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