diff --git a/AGENTS.md b/AGENTS.md index 3e631a92f..7fdd4c058 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Record a new or remeasured program's CI cost (used to balance the test shards) i Before opening a PR that adds or changes corpus, diagnostic, or frontend fixtures, run `pnpm test:ts7 --baselines-only`. This fast completeness check uses the same fixture discovery as the parity suite; it does not type-check or replace behavioral parity. Generate missing preflight/order baselines, review their diagnostics and module order, and run the affected `order-parity.test.ts` batches before pushing. Keep existing baseline entries unchanged unless the corresponding behavior intentionally changes. The full `pnpm test:ts7` command also checks completeness before starting its sweep. -A change that must not cost good code anything, such as a compatibility fix that replaces a refusal, proves it with the zero-cost gate. The gate compiles the benchmark workloads, the self-hosted compiler, and the TypeScript corpus with two built checkouts. It requires byte-identical IR, LLVM, and release executables, and compile time within tolerance. `--allow=` names the change's pattern signature: programs whose sources match may differ, and the PR lists them. Any other difference fails the gate, which names the first differing function. The gate also enforces the two-tier runtime separation: no executable may gain symbols from `DYNAMIC_TIER_PREFIXES` in `scripts/zero-cost-compare.mjs`, and fully typed benchmarks may link none; extend that list as dynamic-tier units land. `--quick` checks only the benchmarks; `--cachegrind` adds per-function instruction counts for executables that differ. Run it in a Sandbox with both checkouts prepared: +A change that must not cost good code anything, such as a compatibility fix that replaces a refusal, proves it with the zero-cost gate. The gate compiles the benchmark workloads, the self-hosted compiler, and the TypeScript corpus with two built checkouts. It requires byte-identical IR, LLVM, and release executables, and compile time within tolerance. `--allow=` names the change's pattern signature: programs whose sources match may differ, and the PR lists them. The gate classifies every other difference. A program is `runtime-only` when its IR and LLVM are identical and its executable differs only through runtime objects that differ between the two checkouts' runtime packs; the gate lists the changed runtime functions each such program links and the size change, and passes with a note, so a runtime C change does not fail the gate when no program's code changes. A program is `nondeterministic` when the baseline, building it again, differs from its own first build in the same artifacts; it also passes with a note. Any other difference is `differs` and fails the gate, which names the first differing function. `--strict` fails runtime-only and nondeterministic programs too, for a change that must leave every executable byte-identical. The gate also enforces the two-tier runtime separation: no executable may gain symbols from `DYNAMIC_TIER_PREFIXES` in `scripts/zero-cost-compare.mjs`, and fully typed benchmarks may link none; extend that list as dynamic-tier units land. `--quick` checks only the benchmarks; `--cachegrind` adds per-function instruction counts for executables that differ. Run it in a Sandbox with both checkouts prepared: ```bash node scripts/zero-cost-gate.mjs --baseline=/vercel/sandbox/prev --candidate=. --allow='\barguments\b' diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 0475e311e..8196460cf 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -42,7 +42,7 @@ Parsing, type checking, lowering, and LLVM IR generation run one after another o ### Partitions -The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 1 MiB of LLVM text is one partition, and up to 2 MiB is four. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets. +The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 1 MiB of LLVM text is one partition, and up to 2 MiB is four. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets. `dev` executables, whose partitions are compiled on their own with nothing imported, are divided from 64 KiB: up to 128 KiB is four partitions, and larger `dev` programs get at least 8. diff --git a/docs/content/docs/platforms.mdx b/docs/content/docs/platforms.mdx index 72cd65a3c..462a043ba 100644 --- a/docs/content/docs/platforms.mdx +++ b/docs/content/docs/platforms.mdx @@ -53,6 +53,8 @@ fib-linux: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically Linux executables use the native Linux implementations of the event loop (epoll), servers, TLS (with the system's CA bundle), and `fs.watch`, and they are tested against Node.js on Linux, including Alpine. +An executable with at least 4 MiB of program code is linked with 2 MiB segment alignment when lld or zig links it, so the kernel can map its code with 2 MiB pages instead of 4 KiB pages. This takes effect when transparent huge pages are set to `madvise` or `always` and the file system keeps files in the page cache as 2 MiB folios (XFS, for example). scriptc writes such an executable with one write so that the page cache holds it that way; a copy written in small chunks maps with 4 KiB pages until it is read from disk again. The alignment also aligns the executable's load address to 2 MiB, which leaves 19 bits of address randomization for it instead of 28. Smaller executables, and links by GNU ld, keep 4 KiB alignment. + ### Windows (x86_64) ```console diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index 57a43e67a..f3883fe8d 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -34,8 +34,8 @@ 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`. - `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. + In TypeScript, `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 and `Readable.toWeb` are unsupported, and in TypeScript, so are a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough`. 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. @@ -60,8 +60,9 @@ The `readline/promises` `Readline` class writes to the same outputs. `readline.emitKeypressEvents` emits `'keypress'` events on `process.stdin` or a readable stream, shared with terminal interfaces on that stream as in Node; a listener's first parameter must admit `undefined`, which it receives for escape sequences. Chained listener registration is unsupported. - `node:stream/web` exports the same `ReadableStream` class as the global; in TypeScript, its other classes are unsupported. - In JavaScript, `ReadableStream`, `ReadableStreamDefaultReader`, `ReadableStreamDefaultController`, `CountQueuingStrategy`, and `ByteLengthQueuingStrategy`, as globals and as `node:stream/web` exports, are constructor values with Node's prototypes, brand checks, and errors. - Their underlying sources (`start`, `pull`, `cancel`), queuing strategies with a `size` function, readers, `tee`, `ReadableStream.from`, and async iteration settle promises in Node's order. + `node:stream/web` exports the same classes as the globals; in TypeScript, only `ReadableStream` and the identity `TransformStream` have lowerings. + In JavaScript, `ReadableStream`, `WritableStream`, and `TransformStream` with their default readers, writers, and controllers, `CountQueuingStrategy`, `ByteLengthQueuingStrategy`, `TextEncoderStream`, and `TextDecoderStream`, as globals and as `node:stream/web` exports, are constructor values with Node's prototypes, brand checks, and errors. + Their underlying sources, sinks, and transformers, queuing strategies with a `size` function, readers and writers, `tee`, `pipeTo` and `pipeThrough` with their options and an abort signal, `ReadableStream.from`, and async iteration settle promises in Node's order, and the streams take own properties. + `TextDecoderStream` decodes UTF-8; its other encodings are unsupported. A JavaScript `ReadableStream` given as a `Response` or `Request` body, or as `fetch`'s body, is read through a stream of its own: `response.body` is that stream rather than the one passed in, and the stream passed in is locked from the start. - In JavaScript, a byte stream (`type: 'bytes'`), `pipeTo`, `pipeThrough`, `Object.getPrototypeOf` on these streams, readers, controllers, and strategies, their use as `WeakMap` and `WeakSet` keys, `ReadableStream.from` of a function that is not iterable, and the other `node:stream/web` classes are unsupported. + In JavaScript, a byte stream (`type: 'bytes'`), a BYOB reader, a class that extends one of these classes, `Object.getPrototypeOf` on these values, their use as `WeakMap` and `WeakSet` keys, and `ReadableStream.from` of a function that is not iterable are unsupported. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 56e26c232..387f5b21c 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -81289,9 +81289,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" } }, { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 43aa5e38b..00d6f8db3 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -111929,6 +111929,8 @@ "tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/web-stream-readable-untyped.mjs", + "tests/corpus/web-stream-transform-pipes-untyped.mjs", + "tests/corpus/web-stream-writable-untyped.mjs", "tests/harness/fetch-conformance.test.ts" ] }, @@ -113011,8 +113013,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webstreams" + "status": "partial", + "evidence": "compiler-feature:webstreams.WritableStream", + "tests": [ + "tests/corpus/web-stream-writable-untyped.mjs" + ] }, "anchorSource": "exact" }, @@ -113405,7 +113410,8 @@ "status": "partial", "evidence": "compiler-feature:webstreams.TransformStream", "tests": [ - "tests/corpus/7743-transform-stream-identity.ts" + "tests/corpus/7743-transform-stream-identity.ts", + "tests/corpus/web-stream-transform-pipes-untyped.mjs" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 25a693c7c..1242ad9a7 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -434,7 +434,8 @@ { "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/web-stream-readable-untyped.mjs", "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": "webstreams", "symbols": ["TransformStream"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts", "tests/corpus/web-stream-transform-pipes-untyped.mjs"] }, + { "chapter": "webstreams", "symbols": ["WritableStream"], "status": "partial", "evidence": ["tests/corpus/web-stream-writable-untyped.mjs"] }, { "chapter": "globals", "signatures": ["Class: `TransformStream`"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] } ], "dedicated": [ diff --git a/packages/compiler/src/backend/huge-text.test.ts b/packages/compiler/src/backend/huge-text.test.ts new file mode 100644 index 000000000..39ef52408 --- /dev/null +++ b/packages/compiler/src/backend/huge-text.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { + HUGE_TEXT_MIN_CODE_BYTES, + elfObjectCodeBytes, + hugeTextLinkerArgs, + hugeTextProgram, +} from "./huge-text.js"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** An ELF64 little-endian relocatable object's headers: one section per + * entry, as [type, flags, size]. Section contents are left out, since only + * the headers are read. */ +function elfObject(sections: readonly [number, number, number][]): Buffer { + const shoff = 64; + const bytes = Buffer.alloc(shoff + (sections.length + 1) * 64); + bytes.writeUInt32BE(0x7f454c46, 0); + bytes[4] = 2; // ELFCLASS64 + bytes[5] = 1; // ELFDATA2LSB + bytes[6] = 1; + bytes.writeUInt16LE(1, 16); // ET_REL + bytes.writeUInt16LE(62, 18); // EM_X86_64 + bytes.writeBigUInt64LE(BigInt(shoff), 40); + bytes.writeUInt16LE(64, 52); + bytes.writeUInt16LE(64, 58); + bytes.writeUInt16LE(sections.length + 1, 60); + sections.forEach(([type, flags, size], index) => { + const at = shoff + (index + 1) * 64; + bytes.writeUInt32LE(type, at + 4); + bytes.writeBigUInt64LE(BigInt(flags), at + 8); + bytes.writeBigUInt64LE(BigInt(size), at + 32); + }); + return bytes; +} + +function file(name: string, bytes: Buffer): string { + const dir = mkdtempSync(join(tmpdir(), "scriptc-huge-text-")); + dirs.push(dir); + const path = join(dir, name); + writeFileSync(path, bytes); + return path; +} + +const PROGBITS = 1; +const NOBITS = 8; +const ALLOC = 0x2; +const EXECINSTR = 0x4; + +test("an object's code is the size of its executable PROGBITS sections", () => { + const object = file( + "program.o", + elfObject([ + [PROGBITS, ALLOC | EXECINSTR, 3 << 20], + [PROGBITS, ALLOC | EXECINSTR, 1000], + [PROGBITS, ALLOC, 5 << 20], + [NOBITS, ALLOC, 7 << 20], + ]), + ); + expect(elfObjectCodeBytes(object)).toBe((3 << 20) + 1000); + expect(elfObjectCodeBytes(file("not-elf.o", Buffer.from("not an object")))).toBe(0); + expect(elfObjectCodeBytes(join(object, "..", "missing.o"))).toBe(0); +}); + +test("only Linux programs with an interior 2 MiB window of code link for huge pages", () => { + const linux = { platform: "linux" } as const; + const half = file( + "half.o", + elfObject([[PROGBITS, ALLOC | EXECINSTR, HUGE_TEXT_MIN_CODE_BYTES / 2]]), + ); + const small = file("small.o", elfObject([[PROGBITS, ALLOC | EXECINSTR, 1 << 20]])); + expect(hugeTextProgram(linux, [half, small])).toBe(false); + // Partitions of one program count together. + expect(hugeTextProgram(linux, [half, half])).toBe(true); + expect(hugeTextProgram({ platform: "darwin" } as const, [half, half])).toBe(false); + expect(hugeTextProgram(linux, [])).toBe(false); +}); + +test("the 2 MiB alignment applies to lld and zig links of huge-text programs", () => { + expect(hugeTextLinkerArgs(true, true)).toEqual(["-Wl,-z,max-page-size=0x200000"]); + // GNU ld pads the file to the alignment. + expect(hugeTextLinkerArgs(true, false)).toEqual([]); + expect(hugeTextLinkerArgs(false, true)).toEqual([]); +}); diff --git a/packages/compiler/src/backend/huge-text.ts b/packages/compiler/src/backend/huge-text.ts new file mode 100644 index 000000000..4db824074 --- /dev/null +++ b/packages/compiler/src/backend/huge-text.ts @@ -0,0 +1,110 @@ +/** + * Text on 2 MiB pages for large Linux executables. + * + * The kernel maps a 2 MiB window of an executable's text with one huge page + * when the window's virtual address and file offset are both 2 MiB aligned + * and the page cache holds the window as one 2 MiB folio. A link with + * `-z max-page-size=0x200000` keeps every segment's address congruent to its + * file offset modulo 2 MiB, so every 2 MiB window inside the text segment + * qualifies. lld and zig add no file padding for it; GNU ld pads the file to + * the alignment, so links that fall back to it leave the flag out. + * + * The alignment also aligns a position-independent executable's load + * address to 2 MiB, which leaves 19 bits of address randomization for it + * instead of 28. Only executables whose program code fills an interior 2 MiB + * window can gain from huge pages, so smaller executables link as before. + * + * lld writes its output through a memory mapping, which leaves the file in + * the page cache as 4 KiB folios. A large executable is published by writing + * its bytes with one write instead, which leaves 2 MiB folios where the file + * system supports them. The runtime advises the interior windows huge pages + * at startup (scr_lib.c), so a file that is read back from disk is read as + * 2 MiB folios too. + */ +import { + chmodSync, + closeSync, + openSync, + readFileSync, + readSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import type { NativeTargetSpec } from "./targets.js"; + +/** Program code from which an executable is linked for huge pages: text of + * this size has at least one 2 MiB window inside it wherever it starts. */ +export const HUGE_TEXT_MIN_CODE_BYTES = 4 << 20; + +const SHF_EXECINSTR = 0x4n; + +/** The bytes of executable sections in an ELF64 little-endian object, or 0 + * when the file is not one or cannot be read. Reads only the file and + * section headers. */ +export function elfObjectCodeBytes(path: string): number { + let fd: number; + try { + fd = openSync(path, "r"); + } catch { + return 0; + } + try { + const header = Buffer.alloc(64); + if (readSync(fd, header, 0, 64, 0) !== 64) return 0; + const view = new DataView(header.buffer, header.byteOffset, header.byteLength); + if (view.getUint32(0, false) !== 0x7f454c46 || header[4] !== 2 || header[5] !== 1) return 0; + const shoff = Number(view.getBigUint64(40, true)); + const shentsize = view.getUint16(58, true); + let shnum = view.getUint16(60, true); + if (shentsize !== 64 || shoff === 0) return 0; + if (shnum === 0) { + const first = Buffer.alloc(64); + if (readSync(fd, first, 0, 64, shoff) !== 64) return 0; + shnum = Number(new DataView(first.buffer, first.byteOffset, 64).getBigUint64(32, true)); + } + const table = Buffer.alloc(shnum * 64); + if (readSync(fd, table, 0, table.length, shoff) !== table.length) return 0; + const sections = new DataView(table.buffer, table.byteOffset, table.byteLength); + let code = 0; + for (let i = 0; i < shnum; i++) { + const at = i * 64; + // SHT_PROGBITS sections with SHF_EXECINSTR. + if (sections.getUint32(at + 4, true) !== 1) continue; + if ((sections.getBigUint64(at + 8, true) & SHF_EXECINSTR) === 0n) continue; + code += Number(sections.getBigUint64(at + 32, true)); + } + return code; + } finally { + closeSync(fd); + } +} + +/** Whether a link of these program objects is laid out for huge pages. */ +export function hugeTextProgram( + target: Pick, + programObjects: readonly string[], +): boolean { + if (target.platform !== "linux") return false; + let code = 0; + for (const object of programObjects) { + code += elfObjectCodeBytes(object); + if (code >= HUGE_TEXT_MIN_CODE_BYTES) return true; + } + return false; +} + +/** The link arguments that align a huge-text executable's segments to + * 2 MiB, for links by lld or zig. */ +export function hugeTextLinkerArgs(hugeText: boolean, linksWithLldOrZig: boolean): string[] { + return hugeText && linksWithLldOrZig ? ["-Wl,-z,max-page-size=0x200000"] : []; +} + +/** Rewrites a linked huge-text executable with one write, so the page cache + * holds it in 2 MiB folios; its caller then publishes it as usual. */ +export function rewriteHugeTextExecutable(linked: string): void { + const written = `${linked}.written`; + writeFileSync(written, readFileSync(linked)); + chmodSync(written, statSync(linked).mode & 0o7777); + renameSync(written, linked); +} diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index c3c0cb5a7..19cff2d5f 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -13,6 +13,7 @@ import type { NativeArtifactDependency } from "./native/contracts.js"; import { needsDarwinDebugSymbols } from "./debug-symbols.js"; import { loadRuntimePack, type RuntimePackSelection } from "./runtime-pack.js"; import { executableLinkInputs } from "./link-plan-core.js"; +import { hugeTextProgram } from "./huge-text.js"; import type { NativeTargetSpec, WindowsSubsystem } from "./targets.js"; export interface NativeLinkPlan { @@ -30,6 +31,8 @@ export interface NativeLinkPlan { darwinDebugSymbols?: boolean; /** Linked with `--static`: the linked executable is checked before use. */ staticLink?: boolean; + /** Program code large enough for text on huge pages (huge-text.ts). */ + hugeText?: boolean; } export async function createNativeLinkPlan(options: { @@ -85,5 +88,11 @@ export async function createNativeLinkPlan(options: { programObjectDependencies: [...(options.programObjectDependencies ?? [])], runtimePack, ...(options.staticLink === true ? { staticLink: true } : {}), + ...(hugeTextProgram(options.target, [ + options.programObject, + ...(options.programPartitions ?? []), + ]) + ? { hugeText: true } + : {}), }; } diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index fe476360d..ab76597ed 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -24,6 +24,7 @@ import { type NativeArtifactDependency } from "./native/contracts.js"; import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js"; import type { NativeLinkPlan } from "./link-plan.js"; import { inputsWithoutLld, lldLinkerArgs } from "./lld.js"; +import { hugeTextLinkerArgs, rewriteHugeTextExecutable } from "./huge-text.js"; import { systemLibraryLinkerArgs } from "./link-plan-core.js"; import { createDarwinDebugSymbols, @@ -248,11 +249,13 @@ export async function linkNativeExecutable( stagedRoot = staged.root; // Linux links with lld when the default driver finds it. const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : []; + const zig = explicitLinker === undefined && plan.target.defaultLinker === "zig"; const linkArgs = (withLld: boolean): string[] => { const inputs = plan.inputs.map((input) => staged.replacements.get(input) ?? input); return [ ...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []), ...(withLld ? lld : []), + ...hugeTextLinkerArgs(plan.hugeText === true, (withLld && lld.length !== 0) || zig), ...plan.driverFlags, ...mapArgs, ...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)), @@ -298,6 +301,7 @@ export async function linkNativeExecutable( }); const output = await stat(privateOut); if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable"); + if (plan.hugeText === true) rewriteHugeTextExecutable(privateOut); if (mapArgs.length !== 0) { const refusal = staticExecutableRefusal(plan.target, true, await readFile(linkMap, "utf8")); if (refusal !== null) throw new NativeCodegenError("SC3002", refusal); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index becd24805..dda240fc7 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -9,7 +9,8 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; import { borrowsMapReadInputs } from "./map-read-lifetimes.js"; import { LazyCaptures } from "./lazy-captures.js"; -import { storedLastUseRead } from "./local-extents.js"; +import { BodyLastUses, storedLastUseRead } from "./local-extents.js"; +import { InternalCompilerError } from "../../errors.js"; interface ForwardedUse { callee: string; @@ -497,6 +498,9 @@ export function analyzeCallLifetimes( // environment box takes a reference of its own when it is created. const unchangedCaptures = lazyCaptures.parameters(fn); const walks = walkParameters.get(fn.name); + // One walk of the body answers every parameter's last use (consumedParameter). + let lastUses: BodyLastUses | undefined; + const bodyLastUses = (): BodyLastUses => (lastUses ??= new BodyLastUses(fn.body)); fn.params.forEach((param, index) => { const local = locals.get(param.localId); if ( @@ -511,7 +515,7 @@ export function analyzeCallLifetimes( !walks?.has(index) && !unchangedCaptures.has(local.id) && param.name !== "this" && - consumedParameter(fn, param.localId) + consumedParameter(fn, param.localId, bodyLastUses) ) consumed.push({ fn: fn.name, index }); } @@ -626,6 +630,9 @@ export function analyzeCallLifetimes( return result; } +/** SCRIPTC_EMIT_FACTS_VERIFY=1: every BodyLastUses answer is checked against storedLastUseRead. */ +const verifiesLastUses = process.env["SCRIPTC_EMIT_FACTS_VERIFY"] === "1"; + /** A parameter whose last use in the body stores it (a field, record or * element store, a record literal field, a push) and moves its reference * there (lastUseRead). Its owned convention lets a caller hand over a @@ -633,8 +640,12 @@ export function analyzeCallLifetimes( * copy while the caller releases its temporary right after the call. The * read must be the only node of its kind in the function, like every * moved read. */ -function consumedParameter(fn: IrFunction, localId: string): boolean { - const read = storedLastUseRead(fn.body, -1, localId, true); +function consumedParameter(fn: IrFunction, localId: string, lastUses: () => BodyLastUses): boolean { + const read = lastUses().storedRead(localId); + if (verifiesLastUses && read !== storedLastUseRead(fn.body, -1, localId, true)) + throw new InternalCompilerError( + `call lifetimes: the body's last use of ${localId} in ${fn.name} differs`, + ); if (read === null) return false; let count = 0; const expr = (e: IrExpr): boolean => { diff --git a/packages/compiler/src/backend/llvm/counted-loops.test.ts b/packages/compiler/src/backend/llvm/counted-loops.test.ts new file mode 100644 index 000000000..b5cd5dd24 --- /dev/null +++ b/packages/compiler/src/backend/llvm/counted-loops.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import type { IntegerCountedForLoop } from "../../ir/integer-loops.js"; +import { F64, type IrExpr } from "../../ir/ir.js"; +import type { IntegerRange } from "../../ir/integer-ranges.js"; +import { BlockBuilder } from "./blocks.js"; +import { emitCountedLoopLimit } from "./counted-loops.js"; +import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; + +const loc = { file: "test.ts", start: 0, end: 0 }; + +/** The endpoint emitCountedLoopLimit answers for a limit that emits as + * `value` and has the proven `range`, and the lines it writes. */ +function endpoint( + value: LlValue, + range: IntegerRange, + step: number, + inclusive: boolean, +): { endpoint: string; lines: string } { + const limit: IrExpr = { loc, kind: "varRef", localId: "n", type: F64 }; + const B = new BlockBuilder(); + const host = { + B, + integerRanges: new Map([[limit, range]]), + emitExpr: () => value, + declare: () => {}, + } as unknown as LlvmEmitterContext; + const loop = { step, inclusive, limit } as unknown as IntegerCountedForLoop; + const result = emitCountedLoopLimit(host, loop); + return { endpoint: result, lines: B.render() }; +} + +test("a negative int32 limit whose ToUint32 view is a literal keeps its sign", () => { + // A number literal's value carries its ToUint32 bits as a literal, so -1 + // has the view 4294967295; the endpoint is -1, as in `i > -1`. + const minusOne: LlValue = { name: "-1.0", type: F64, uint32: "4294967295" }; + expect(endpoint(minusOne, { min: -1, max: -1 }, -1, false).endpoint).toBe("-1"); + const inclusive = endpoint(minusOne, { min: -1, max: -1 }, -1, true); + expect(inclusive.lines).toContain("add nsw i64 -1, -1"); + const minusTwo: LlValue = { name: "-2.0", type: F64, uint32: "4294967294" }; + expect(endpoint(minusTwo, { min: -2, max: -2 }, 1, false).endpoint).toBe("-2"); + // A uint32 past int32 stays positive. + const wide: LlValue = { name: "4294967295.0", type: F64, uint32: "4294967295" }; + expect(endpoint(wide, { min: 4294967295, max: 4294967295 }, 1, false).endpoint).toBe( + "4294967295", + ); +}); diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 88af25e47..454fea32a 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -13,7 +13,10 @@ import { integerOrNaNRange } from "./wrapping-sums.js"; /** The caller has proved or guarded the direction's finite endpoint. Clamp * the opposite infinity before integer conversion. Fractional limits become - * exclusive integer endpoints for signed comparisons in either direction. */ + * exclusive integer endpoints for signed comparisons in either direction. A + * limit that is an exact integer (an int32 local, an integer view, or a + * proven range) is its own floor and ceiling and within ±2^53, so it is the + * endpoint without the double conversion and rounding. */ export function emitCountedLoopLimit( host: LlvmEmitterContext, loop: IntegerCountedForLoop, @@ -35,6 +38,24 @@ export function emitCountedLoopLimit( } const B = host.B; const value = host.emitExpr(loop.limit); + const exact = exactInteger(host, value, loop.limit); + if (exact) { + // An i32 literal may be the unsigned bit pattern of a negative int32 + // (a ToUint32 view): read it back with the integer's signedness. + const integer = /^-?\d+$/.test(exact.name) + ? String( + exact.type === "i64" + ? Number(exact.name) + : exact.signed + ? Number(exact.name) | 0 + : Number(exact.name) >>> 0, + ) + : widenInteger(host, exact); + if (!loop.inclusive) return integer; + const limit = B.tmp(); + B.line(`${limit} = add nsw i64 ${integer}, ${ascending ? 1 : -1}`); + return limit; + } const within = B.tmp(), bounded = B.tmp(), rounded = B.tmp(), diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 427e439ad..02c12305c 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -670,6 +670,9 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { // checked dynamic call. Keeping that fallback out of line leaves the // tests small enough to inline at each call site. if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`; + // A keyed record read's inherited-method miss path (record-inherited.ts) + // stays out of line, so the hit path keeps its own small frame. + else if (fn.name.startsWith("%rec.inherited.")) attrs = `noinline cold ${attrs}`; else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`; if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`; if (unoptimized && attrs === FN_ATTRS) attrs = `noinline optnone ${attrs}`; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 975a2b172..edad06ff3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,6 +1,7 @@ import type { TargetCpu } from "../target-cpu.js"; import { loopEntryInlines } from "./loop-entry-versions.js"; import type { FunctionLayout } from "./function-layout.js"; +import type { CompilationTiming } from "../../timing.js"; import { relieveHeapPressure } from "../../heap-pressure.js"; import { analyzeFrameEscapes, type FrameEscapes } from "./frame-escapes.js"; import { wasmBoundsChecks } from "./wasm-modules.js"; @@ -299,8 +300,10 @@ import { emitBytesIntrinsic, } from "./expr-bytes.js"; import { + borrowedRecordKeyGetOr, emitRecordKeyGet, emitRegexIntrinsic, + immortalDefault, keyedRecordReadInto, pointerRecordKeyGet, } from "./expr-records.js"; @@ -522,6 +525,11 @@ export interface LlvmTargetOptions { forkPrimitives?: ForkPrimitives | undefined; /** Functions to write first, by tier (function-layout.ts). */ functionLayout?: FunctionLayout; + /** Marks emission's serial steps: `llvm-emit-rewrites` after the module + * passes, `llvm-emit-int32` after int32 selection, and + * `llvm-emit-analyses` after the emitter's whole-program analyses. The + * caller's next phase is the function bodies. */ + timing?: CompilationTiming; /** Exact frontend sources for dev-build locations and variable metadata. */ debugSources?: ReadonlyMap; /** Pointer width of the target C ABI. Native targets are 64-bit today. */ @@ -706,6 +714,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { }; // Keep source storage intact for debugger inspection in dev builds. if (options.debugSources !== undefined) return new LlEmitter(mod, options, null, null); + const timing = options.timing ?? (() => {}); const passed = versionParameters( addRecordResultEntries( scalarizeLiteralResults( @@ -737,6 +746,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { ), ), ); + timing("llvm-emit-rewrites"); let emitted = selectInt32Arrays(passed, proven, resultEntryTargets); let prepared = rounds.last; // A proof that revised functions analyzed the module of the functions it @@ -758,7 +768,15 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { } if (process.env["SCRIPTC_INT32_VERIFY"] === "1") verifyInt32Selection(passed, emitted, prepared?.mod === emitted ? prepared : null); - return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null); + timing("llvm-emit-int32"); + const emitter = new LlEmitter( + emitted, + options, + null, + prepared?.mod === emitted ? prepared : null, + ); + timing("llvm-emit-analyses"); + return emitter; } /** SCRIPTC_INT32_VERIFY=1: throws unless the selection with revisions @@ -2128,7 +2146,7 @@ export class LlEmitter { (options.targetTriple ? /-linux-(?:gnu|musl)/.test(options.targetTriple) : process.platform === "linux") - ? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded) + ? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded, this.nodeIndex) : new Set(); this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; @@ -2221,6 +2239,7 @@ export class LlEmitter { (type.kind !== "union" || this.nullableUnions.single(type.unionId)?.arm.kind !== "string"), this.walkBorrowsByFunction.get(fn.name) ?? new Set(), + this.nodeIndex, ); if (rebound.size === 0) continue; this.reboundByFunction.set(fn.name, rebound); @@ -3576,8 +3595,14 @@ export class LlEmitter { /** Registers a SLOT whose current contents the frame owns (conditional * results: optional chains, branch joins that park ownership). */ - ownSlot(slot: string, type: IrType): void { - if (isRefCounted(type)) this.currentFrame().push({ name: slot, type, slot: true }); + ownSlot(slot: string, type: IrType, rarelyFilled = false): void { + if (isRefCounted(type)) + this.currentFrame().push({ + name: slot, + type, + slot: true, + ...(rarelyFilled ? { rarelyFilled: true } : {}), + }); } /** The innermost lexical scope releases a frame object's reference @@ -3689,6 +3714,17 @@ export class LlEmitter { this.B.line(`${t} = load ptr, ptr ${v.name}`); if (v.boxed) { this.B.line(`call void ${boxReleaseSym(this.shapeHost)}(ptr ${t})`); + } else if (v.rarelyFilled && !this.B.isTerminated()) { + // Skip the release call on the common empty path. + const filled = this.B.tmp(); + this.B.line(`${filled} = icmp ne ptr ${t}, null`); + const release = this.B.newLabel("slot.release"), + join = this.B.newLabel("slot.release.j"); + this.B.condBr(filled, release, join); + this.B.startBlock(release); + this.releaseFrame(t, v.type); + this.B.br(join); + this.B.startBlock(join); } else { this.releaseFrame(t, v.type); } @@ -6298,6 +6334,11 @@ export class LlEmitter { case "recordGet": case "recordKeyGet": return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj); + // `r[k] ?? inherited(r, k)` whose default is immortal. + case "nullish": { + const read = borrowedRecordKeyGetOr(this, e); + return read !== null && immortalDefault(this, e.right) && this.unownedWalk(read); + } case "mapIntrinsic": { // A Map binding's own pointer (maps are not plainReference types). if (!this.borrowsWithoutOwning(e) || e.receiver.kind !== "varRef") return false; @@ -7574,6 +7615,8 @@ export class LlEmitter { return pointerRecordKeyGet(this, e); case "mapIntrinsic": return pointerMapGet(this, e); + case "nullish": + return borrowedRecordKeyGetOr(this, e) !== null; // emitReadReceiver lends a required element of a borrowed array when // computing the index removes no edge. case "arrayGet": diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index 394db8a2f..ed9537209 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -35,6 +35,8 @@ export interface LlValue { * convert it instead of the sum (expr-bytes.ts). */ offsetIndex?: OffsetIndex; slot?: boolean; + /** A frame slot that usually stays NULL: its release tests it first. */ + rarelyFilled?: boolean; /** A sequence local held in a capture box; release the box itself. */ boxed?: boolean; /** A group arena list slot (arenas.ts): the frame closes the newest diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 58f1297b3..e68b0ed4c 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -1,7 +1,14 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag, unionWideningTags } from "../../ir/analysis.js"; -import { type IrExpr, type IrType, isRefCounted, type SrcLoc, typeEquals } from "../../ir/ir.js"; +import { + type IrExpr, + type IrType, + isRefCounted, + isUnitType, + type SrcLoc, + typeEquals, +} from "../../ir/ir.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, LlValue } from "./expr-context.js"; import { f64Lit, llvmCommentText } from "./common.js"; @@ -11,6 +18,7 @@ import { canReplaceWith, regexLiteralCompiles } from "./regex-matchers.js"; import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js"; import { preservesRegexInputs } from "./reference-effects.js"; import { emitNumberMapSlot, emitStringMapEntry } from "./map-number-lookup.js"; +import { everyStmtList } from "../../ir/traverse.js"; /** Widen one tagged read. The source is borrowed unless the overflow map * supplied an owned value. Each selected reference payload is retained @@ -490,6 +498,7 @@ export function pointerRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolea export function emitBorrowedRecordKeyGet( host: LlvmEmitterContext, e: IrExpr & { kind: "recordKeyGet" }, + fallback: IrExpr | null = null, ): LlValue { const B = host.B; if (e.type.kind !== "union") @@ -499,6 +508,15 @@ export function emitBorrowedRecordKeyGet( const key = host.canBorrowReceiver(e.key) ? host.emitReadReceiver(e.key) : host.emitExpr(e.key); const slot = B.slot(); B.entryAllocas.push(`${slot} = alloca ptr`); + // A default that needs an owner gets a frame slot, empty on every path + // until a miss fills it. + let owner: string | null = null; + if (fallback !== null && !immortalDefault(host, fallback)) { + owner = B.slot(); + B.entryAllocas.push(`${owner} = alloca ptr`); + B.line(`store ptr null, ptr ${owner}`); + host.ownSlot(owner, resultType, true); + } const join = B.newLabel("rkb.j"); const shape = host.recordShape(e.shapeId); for (const f of e.overflowOnly === true ? [] : shape.fields) { @@ -528,8 +546,23 @@ export function emitBorrowedRecordKeyGet( B.br(join); B.startBlock(ln); } - const undefTag = undefinedArmTag(resultType, host.unionsById); - B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`); + if (fallback !== null && immortalDefault(host, fallback)) { + // An immortal default (an interned function value or undefined) needs + // no owner at all. + const value = host.emitExpr(fallback); + host.toHeap(value); + B.line(`store ptr ${value.name}, ptr ${slot}`); + } else if (fallback !== null && owner !== null) { + // `r[k] ?? d` (borrowedRecordKeyGetOr): the default is owned by a + // statement-frame slot, which the consumer may borrow like a hit. + const value = host.emitExpr(fallback); + host.toHeap(value); + B.line(`store ptr ${value.name}, ptr ${owner}`); + B.line(`store ptr ${value.name}, ptr ${slot}`); + } else { + const undefTag = undefinedArmTag(resultType, host.unionsById); + B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`); + } B.br(join); B.startBlock(join); const t = B.tmp(); @@ -537,6 +570,54 @@ export function emitBorrowedRecordKeyGet( return { name: t, type: e.type }; } +/** `r[k] ?? d` in its pass-through shape over a pointerRecordKeyGet whose + * stored values are never unit (so only a miss evaluates `d`): the hit + * lends the stored pointer and the default is frame-owned, so the result + * borrows for the rest of the statement. */ +export function borrowedRecordKeyGetOr( + host: LlvmEmitterContext, + e: IrExpr, +): (IrExpr & { kind: "recordKeyGet" }) | null { + if (e.kind !== "nullish" || e.left.kind !== "recordKeyGet") return null; + if (!typeEquals(e.type, e.left.type) || !pointerRecordKeyGet(host, e.left)) return null; + const shape = host.recordShape(e.left.shapeId); + const holdsUnit = (t: IrType): boolean => + isUnitType(t) || + t.kind === "dyn" || + (t.kind === "union" && (host.unionsById.get(t.unionId)?.arms ?? []).some(isUnitType)); + if (shape.indexValue !== undefined && holdsUnit(shape.indexValue)) return null; + if (e.left.overflowOnly !== true && shape.fields.some((f) => holdsUnit(f.type))) return null; + return e.left; +} + +/** The default is a call to a helper whose every result is immortal: the + * unit arm or a closure over no captures (an interned function value), as + * the unbound inherited-method reads return. */ +export function immortalDefault(host: LlvmEmitterContext, fallback: IrExpr): boolean { + if (fallback.kind !== "call") return false; + const fn = host.fnByName.get(fallback.callee); + if (!fn || fn.async || fn.generator) return false; + const immortal = (v: IrExpr | null): boolean => { + if (v === null) return false; + if (v.kind === "unitLit") return true; + if (v.kind === "unionWrap") return immortal(v.value); + if (v.kind === "closure") { + const target = host.fnByName.get(v.fnName); + return v.captures.length === 0 && target !== undefined && target.captures === undefined; + } + return false; + }; + let ok = true; + everyStmtList(fn.body, { + stmt: (st) => { + if (st.kind === "return" && !immortal(st.value)) ok = false; + return ok; + }, + expr: () => ok, + }); + return ok; +} + /** A keyed read whose result is a scalar union (`r[k]` on a * `Record`, at `number | undefined`) and whose every hit * is a number or boolean arm of it, or an inline field of the same union. diff --git a/packages/compiler/src/backend/llvm/local-extents.ts b/packages/compiler/src/backend/llvm/local-extents.ts index 7706a9c0a..09f6cf22c 100644 --- a/packages/compiler/src/backend/llvm/local-extents.ts +++ b/packages/compiler/src/backend/llvm/local-extents.ts @@ -45,6 +45,15 @@ function lastUse( const { extent, derived } = extentOf(stmts, index, localId, rebound); const last = extent[extent.length - 1]; if (last === undefined) return null; + return lastUseIn(last, localId, derived); +} + +/** The binding's read in `last`, the last statement of its extent, whose derived bindings are `derived`. */ +function lastUseIn( + last: IrStmt, + localId: string, + derived: ReadonlySet, +): { read: IrExpr; stored: boolean } | null { const state: { read: IrExpr | null; stored: boolean; ok: boolean } = { read: null, stored: false, @@ -183,6 +192,109 @@ function collectAssignedExpr(e: IrExpr, assigned: Set): boolean { ); } +/** + * `storedLastUseRead(stmts, -1, localId, true)` for every parameter of one function body, from one walk of the + * body instead of a walk per binding. With `index` -1 and `rebound`, no binding is excluded as assigned, so a + * binding is derived when one of its definitions mentions a derived binding (reachability over "definition + * mentions") and the extent ends at the last statement that mentions one (`absentStmt`). The walk records, for + * each top-level statement, the bindings it mentions, and for each definition, the bindings its value mentions. + */ +export class BodyLastUses { + private readonly stmts: readonly IrStmt[]; + /** The bindings each top-level statement mentions, as `absentStmt` reads them. */ + private readonly mentions: Set[] = []; + /** For each binding, the bindings whose definitions mention it. */ + private readonly definedFrom = new Map(); + + constructor(stmts: readonly IrStmt[]) { + this.stmts = stmts; + for (const s of stmts) { + const mentioned = new Set(); + this.stmt(s, mentioned); + this.mentions.push(mentioned); + } + } + + /** What `storedLastUseRead(stmts, -1, localId, true)` answers. */ + storedRead(localId: string): IrExpr | null { + const derived = new Set([localId]); + const queue = [localId]; + while (queue.length > 0) { + for (const target of this.definedFrom.get(queue.pop()!) ?? []) + if (!derived.has(target)) { + derived.add(target); + queue.push(target); + } + } + let last = -1; + for (let i = this.mentions.length - 1; i >= 0 && last < 0; i--) + for (const id of derived) + if (this.mentions[i]!.has(id)) { + last = i; + break; + } + if (last < 0) return null; + const use = lastUseIn(this.stmts[last]!, localId, derived); + return use !== null && use.stored ? use.read : null; + } + + /** Adds the bindings `s` mentions to `into`, and records the definitions in it. */ + private stmt(s: IrStmt, into: Set): boolean { + if (s.kind === "varDecl" || s.kind === "assign") into.add(s.localId); + if (s.kind === "varDecl" || s.kind === "forOf" || s.kind === "assign") { + const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : s.iterable; + if (value !== null) this.define(s.localId, value); + } + everyStmtChild( + s, + (e) => this.expr(e, into), + (c) => this.stmt(c, into), + ); + return true; + } + + private expr(e: IrExpr, into: Set): boolean { + if (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") into.add(e.localId); + else if (e.kind === "closure" || e.kind === "classRef") + for (const id of e.captures ?? []) into.add(id); + if (e.kind === "assignExpr") this.define(e.localId, e.value); + everyExprChild( + e, + (c) => this.expr(c, into), + (c) => this.stmt(c, into), + ); + return true; + } + + /** A definition of `target` whose value is `value`: every binding the value mentions derives `target`. */ + private define(target: string, value: IrExpr): void { + const mentioned = new Set(); + mentionsOf(value, mentioned); + for (const id of mentioned) { + let targets = this.definedFrom.get(id); + if (targets === undefined) this.definedFrom.set(id, (targets = [])); + targets.push(target); + } + } +} + +/** The bindings an expression mentions, as `absentExpr` reads them. */ +function mentionsOf(e: IrExpr, into: Set): void { + const expr = (x: IrExpr): boolean => { + if (x.kind === "varRef" || x.kind === "assignExpr" || x.kind === "incDec") into.add(x.localId); + else if (x.kind === "closure" || x.kind === "classRef") + for (const id of x.captures ?? []) into.add(id); + everyExprChild(x, expr, stmt); + return true; + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "varDecl" || s.kind === "assign") into.add(s.localId); + everyStmtChild(s, expr, stmt); + return true; + }; + expr(e); +} + /** True while no node of the tree mentions a derived binding. */ function absentExpr(e: IrExpr, derived: ReadonlySet): boolean { if (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") { diff --git a/packages/compiler/src/backend/llvm/rebound-parameters.ts b/packages/compiler/src/backend/llvm/rebound-parameters.ts index b239195dd..722bdf717 100644 --- a/packages/compiler/src/backend/llvm/rebound-parameters.ts +++ b/packages/compiler/src/backend/llvm/rebound-parameters.ts @@ -1,5 +1,9 @@ import type { IrExpr, IrFunction, IrStmt, IrType } from "../../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex } from "../../ir/node-index.js"; + +/** Assignment statements: only these rebind a parameter. */ +const ASSIGN_KINDS = ["assign"] as const; /** Parameters that keep the borrowed calling convention although the body * rebinds them (`source = (source as LiteralType).regularType`). The @@ -15,6 +19,8 @@ export function findReboundParameters( fn: IrFunction, plainReference: (type: IrType) => boolean, excluded: ReadonlySet, + /** The module's node index: a body that assigns no candidate parameter is not walked. */ + nodes?: IrNodeIndex, ): ReadonlySet { const result = new Set(); if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result; @@ -34,6 +40,15 @@ export function findReboundParameters( .map((param) => param.localId), ); if (candidates.size === 0) return result; + // A parameter is rebound only by an assignment statement to it. + if (nodes !== undefined) { + let assigned = false; + eachNodeOfKinds(fn, nodes.scan(ASSIGN_KINDS), (node) => { + if (node.kind === "assign" && candidates.has(node.localId)) assigned = true; + return !assigned; + }); + if (!assigned) return result; + } const rebound = new Set(); const invalid = new Set(); const expr = (e: IrExpr): boolean => { diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index 8524a51fb..78a9b67ad 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -24,6 +24,7 @@ import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js"; import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js"; import { borrowableInputs } from "./borrowed-inputs.js"; import { + borrowedRecordKeyGetOr, emitBorrowedRecordKeyGet, emitStackRecordKeyGet, pointerRecordKeyGet, @@ -104,6 +105,10 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue { if (arrayRead) return emitProjectedArrayRead(emitter, arrayRead); if (e.kind === "recordKeyGet" && emitter.canBorrowReceiver(e)) return emitBorrowedRecordKeyGet(emitter, e); + if (e.kind === "nullish" && emitter.canBorrowReceiver(e)) { + const read = borrowedRecordKeyGetOr(emitter, e); + if (read) return emitBorrowedRecordKeyGet(emitter, read, e.right); + } if (e.kind === "mapIntrinsic" && emitter.canBorrowReceiver(e)) return emitBorrowedMapGet(emitter, e); const narrow = emitter.checkedNarrows.get(e); @@ -160,6 +165,12 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue { no = B.newLabel("read.else"), join = B.newLabel("read.join"); B.condBr(cond.name, yes, no); + // Slots an arm's borrowed read fills (the owner of a `r[k] ?? d` + // default) may hold what the result points to: the enclosing frame + // releases them after the use, and the other arm's path leaves them + // empty so that release skips them. + const armSlots: LlValue[][] = []; + const ends: string[] = []; for (const [label, value] of [ [yes, e.then], [no, e.else_], @@ -168,9 +179,21 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue { emitter.frames.push([]); const result = emitter.emitReadReceiver(value); B.line(`store ptr ${result.name}, ptr ${slot}`); - emitter.releaseTemps(emitter.frames.pop()!); + const frame = emitter.frames.pop()!; + armSlots.push(frame.filter((entry) => entry.slot === true && entry.boxed !== true)); + emitter.releaseTemps(frame.filter((entry) => entry.slot !== true || entry.boxed === true)); + const end = B.newLabel("read.end"); + ends.push(end); + B.br(end); + } + for (const [i, end] of ends.entries()) { + B.startBlock(end); + for (const entry of armSlots[1 - i]!) B.line(`store ptr null, ptr ${entry.name}`); B.br(join); } + for (const entries of armSlots) + for (const entry of entries) + emitter.ownSlot(entry.name, entry.type, entry.rarelyFilled === true); B.startBlock(join); const value = B.tmp(); B.line(`${value} = load ptr, ptr ${slot}`); @@ -315,6 +338,12 @@ export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean { emitter.canBorrowReceiver(e.obj) && emitter.referenceEffects.preserves(e.key) ); + // `r[k] ?? inherited(r, k)` lends a hit the same way; its default is + // owned by the statement frame (so never by a longer-lived alias). + case "nullish": { + const read = borrowedRecordKeyGetOr(emitter, e); + return read !== null && emitter.canBorrowReceiver(read); + } // So does a Map.get while the map keeps it. case "mapIntrinsic": return ( diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 79ac98a7d..56379a443 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -930,6 +930,10 @@ export class ReferenceWrites { out.push(ANY_RECORD_KEY); for (const f of this.records.get(node.shapeId)?.fields ?? []) out.push(`r:${f.name}`); return this.expr(node.key) === null && walk(node.obj); + case "nullish": + // `r[k] ?? d`: a hit is the read's own value; the default runs only + // on a miss and is owned apart from any location. + return walk(node.left); case "mapIntrinsic": // A typed Map.get: any entry of any map. if (node.method !== "get" || node.args.length !== 1 || !borrowsMapReadInputs(node)) diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 821c5351b..fdd0a2f18 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -1,8 +1,14 @@ /* The functions that propagate exceptions by unwinding instead of the * pending flag (see unwindAbiFunctions). */ -import type { IrExpr, IrModule } from "../../ir/ir.js"; -import { everyStmtList } from "../../ir/traverse.js"; +import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js"; import { eachStringField } from "../../ir/fields.generated.js"; +import { eachNodeOfKinds, type IrNodeIndex } from "../../ir/node-index.js"; +import { InternalCompilerError } from "../../errors.js"; + +/** The kinds of every recorded node, for eachNodeOfKinds over whole bodies. */ +const EVERY_KIND: ReadonlySet = new Set(); +/** Virtual calls, which name the methods a vtable must reach. */ +const VIRTUAL_CALL_KINDS = ["virtualCall"] as const; /** How many calls above a `throw` functions keep the pending flag. */ const THROW_LEVELS = 3; @@ -80,6 +86,8 @@ export function unwindAbiFunctions( mod: IrModule, mayThrow: ReadonlySet, excluded: ReadonlySet = new Set(), + /** The module's node index: the bodies' nodes are read from it, in walk order, without walking. */ + nodes?: IrNodeIndex, ): Set { const names = new Set(mod.functions.map((fn) => fn.name)); const blocked = new Set(excluded); @@ -97,16 +105,16 @@ export function unwindAbiFunctions( // class's method of that name. const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls])); const anyClass = new Set(); + const virtualCalls = nodes?.scan(VIRTUAL_CALL_KINDS); for (const fn of mod.functions) - everyStmtList(fn.body, { - expr: (e: IrExpr) => { - if (e.kind !== "virtualCall") return true; - if (e.targets !== undefined) for (const target of e.targets) blocked.add(target); - else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`); - else anyClass.add(e.method); - return true; - }, - stmt: () => true, + // Without the index (or for a function it didn't record) every node + // arrives, statements included: the kind test narrows, not a cast. + eachNodeOfKinds(fn, virtualCalls, (node) => { + if (node.kind !== "virtualCall") return true; + if (node.targets !== undefined) for (const target of node.targets) blocked.add(target); + else if (classes.has(node.className)) blocked.add(`%${node.className}.${node.method}`); + else anyClass.add(node.method); + return true; }); // A hierarchy's vtables hold the methods declared by more than one of its // classes (an override and what it overrides). @@ -161,9 +169,10 @@ export function unwindAbiFunctions( if (list === undefined) methodsNamed.set(m, (list = [])); list.push(`%${cls.name}.${m}`); } + const everyNode = nodes?.scanExcept(EVERY_KIND); for (const fn of mod.functions) { const coroutine = fn.async === true || fn.generator !== undefined; - everyStmtList(fn.body, { + const visit = { expr: (e: IrExpr) => { const kind = e.kind as string; if (kind === "throw" || kind === "rethrow") throws.add(fn.name); @@ -192,13 +201,16 @@ export function unwindAbiFunctions( } return true; }, - stmt: (s) => { + stmt: (s: IrStmt) => { const kind = s.kind as string; if (kind === "throw" || kind === "rethrow") throws.add(fn.name); eachStringField(s, noteName); return true; }, - }); + }; + eachNodeOfKinds(fn, everyNode, (node, statement) => + statement ? visit.stmt(node as IrStmt) : visit.expr(node as IrExpr), + ); } for (const name of referenced) { blocked.add(name); @@ -228,6 +240,13 @@ export function unwindAbiFunctions( if (fn.async === true || fn.generator !== undefined) continue; result.add(fn.name); } + if (nodes !== undefined && process.env["SCRIPTC_EMIT_FACTS_VERIFY"] === "1") { + const walked = unwindAbiFunctions(mod, mayThrow, excluded); + if (walked.size !== result.size || [...walked].some((name) => !result.has(name))) + throw new InternalCompilerError( + "unwind ABI: the functions read from the node index differ from a walk's", + ); + } return result; } diff --git a/packages/compiler/src/backend/native-codegen-core.ts b/packages/compiler/src/backend/native-codegen-core.ts index d75dc2097..e1e726ef4 100644 --- a/packages/compiler/src/backend/native-codegen-core.ts +++ b/packages/compiler/src/backend/native-codegen-core.ts @@ -14,20 +14,32 @@ const PROGRAM_PARTITION_BYTES = 4 * 1024 * 1024; const MIN_PARTITION_BYTES = 512 * 1024; const MIN_PROGRAM_PARTITIONS = 8; const MAX_PROGRAM_PARTITIONS = 64; +/** SINGLE_PARTITION_BYTES and MIN_PARTITION_BYTES of unoptimized (dev) + * executables. */ +const UNOPTIMIZED_SINGLE_PARTITION_BYTES = 64 * 1024; +const UNOPTIMIZED_MIN_PARTITION_BYTES = 32 * 1024; /** Executable objects larger than SINGLE_PARTITION_BYTES are divided into * partitions that the helper simplifies, optimizes and compiles concurrently, * importing small functions across partitions for inlining, and reuses * individually across builds. A divided module gets at least * MIN_PROGRAM_PARTITIONS so that mid-size programs use several cores. The - * count is a power of two that depends only on the module's size, on every - * target, so artifacts never vary with the host and ordinary edits keep every - * function in its partition. */ -export function nativeProgramPartitions(llvmBytes: number): number { - if (llvmBytes <= SINGLE_PARTITION_BYTES) return 1; + * count is a power of two that depends only on the module's size and + * whether it is optimized, on every target, so artifacts never vary with the + * host and ordinary edits keep every function in its partition. + * + * Unoptimized (dev) partitions are compiled on their own with nothing + * imported, so they cost no run time and an edit compiles only the partitions + * whose functions it changes: dev executables are divided from + * UNOPTIMIZED_SINGLE_PARTITION_BYTES, at least + * UNOPTIMIZED_MIN_PARTITION_BYTES each. */ +export function nativeProgramPartitions(llvmBytes: number, unoptimized = false): number { + const single = unoptimized ? UNOPTIMIZED_SINGLE_PARTITION_BYTES : SINGLE_PARTITION_BYTES; + const least = unoptimized ? UNOPTIMIZED_MIN_PARTITION_BYTES : MIN_PARTITION_BYTES; + if (llvmBytes <= single) return 1; const required = Math.max( Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES), - Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / MIN_PARTITION_BYTES)), + Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / least)), ); return Math.min(MAX_PROGRAM_PARTITIONS, 2 ** Math.ceil(Math.log2(required))); } diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index ff0224746..fdee2e5ca 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -408,6 +408,14 @@ test("program partitions depend only on module size", () => { expect(nativeProgramPartitions(32 * megabyte + 1)).toBe(16); expect(nativeProgramPartitions(100 * megabyte)).toBe(32); expect(nativeProgramPartitions(1024 * megabyte)).toBe(64); + // Unoptimized (dev) programs are divided from 64 KiB. + const kilobyte = 1024; + expect(nativeProgramPartitions(64 * kilobyte, true)).toBe(1); + expect(nativeProgramPartitions(64 * kilobyte + 1, true)).toBe(4); + expect(nativeProgramPartitions(128 * kilobyte, true)).toBe(4); + expect(nativeProgramPartitions(128 * kilobyte + 1, true)).toBe(8); + expect(nativeProgramPartitions(megabyte, true)).toBe(8); + expect(nativeProgramPartitions(32 * megabyte + 1, true)).toBe(16); expect(nativePartitionPaths("/build.d/program.o", 3)).toEqual([ "/build.d/program.o", "/build.d/program.part1.o", diff --git a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts index c465c2920..24f603198 100644 --- a/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts +++ b/packages/compiler/src/frontend/lowering/builtins/web-streams-dyn.ts @@ -21,6 +21,13 @@ export const WEB_STREAM_CLASSES = [ "ReadableStreamDefaultController", "CountQueuingStrategy", "ByteLengthQueuingStrategy", + "WritableStream", + "WritableStreamDefaultWriter", + "WritableStreamDefaultController", + "TransformStream", + "TransformStreamDefaultController", + "TextEncoderStream", + "TextDecoderStream", ] as const; const CLASS_NAMES: readonly string[] = WEB_STREAM_CLASSES; @@ -48,6 +55,33 @@ export function webStreamClassKindOf(lowerer: Lowerer, node: ts.Expression): num return index < 0 ? null : index; } +/** Whether a JavaScript source of the program names one of the classes. + * The global object answers them by a computed name only then + * (global-values.ts), so a program without web streams does not link + * them for its other computed global reads. */ +export function programNamesWebStreamClass(lowerer: Lowerer): boolean { + if (lowerer.namesWebStreamClass !== undefined) return lowerer.namesWebStreamClass; + const names: ReadonlySet = new Set(WEB_STREAM_CLASSES); + let found = false; + for (const file of lowerer.program.getSourceFiles()) { + if (file.isDeclarationFile || !isJsSourceFile(file)) continue; + ts.walkPreorder(file, (node) => { + if ( + ts.isIdentifier(node) && + names.has(node.text) && + webStreamClassKindOf(lowerer, node) !== null + ) { + found = true; + return "stop"; + } + return undefined; + }); + if (found) break; + } + lowerer.namesWebStreamClass = found; + return found; +} + export function webStreamClassValue(kind: number, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "webstream.class", args: [numLit(kind, loc)], type: DYN, loc }; } @@ -130,6 +164,8 @@ const WEB_STREAM_INTERFACES: ReadonlySet = new Set([ "ReadableStreamReadValueResult", "ReadableStreamReadDoneResult", "UnderlyingSource", + "UnderlyingSink", + "Transformer", "QueuingStrategy", "QueuingStrategySize", ]); diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 264c99337..21593203d 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -32,6 +32,11 @@ import { builtinNamespaceValue } from "./builtin-namespaces.js"; import { uriFunctionValue } from "./expressions/identifiers.js"; import { navigatorValue } from "./builtins/navigator.js"; import { primitiveCtorClosure } from "./lower-exprs.js"; +import { + WEB_STREAM_CLASSES, + programNamesWebStreamClass, + webStreamClassValue, +} from "./builtins/web-streams-dyn.js"; const LOOKUP_FN = "%global.builtinValue"; @@ -177,9 +182,14 @@ export function globalValuesInstall( const name: IrExpr = { kind: "varRef", localId: "name.0", type: STRING, loc }; const body: IrStmt[] = []; for (const global of [...new Set(names)].sort()) { + const webStream = (WEB_STREAM_CLASSES as readonly string[]).indexOf(global); const value: IrExpr | null = REFUSED.has(global) ? { kind: "unitLit", unit: "null", type: { kind: "nullT" }, loc } - : jsGlobalValueByName(lowerer, global, loc); + : webStream >= 0 + ? programNamesWebStreamClass(lowerer) + ? webStreamClassValue(webStream, loc) + : null + : jsGlobalValueByName(lowerer, global, loc); if (value === null) continue; body.push({ kind: "if", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 273fe4ef2..ec79f3f74 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3730,6 +3730,9 @@ export class Lowerer { /** global-values.ts's answer per JavaScript source: the receiver of its * first read of the global object by a computed name, or null. */ readonly computedGlobalReads = new Map(); + /** web-streams-dyn.ts's answer: whether a JavaScript source names a web + * stream class; undefined until asked. */ + namesWebStreamClass: boolean | undefined = undefined; /** The first lifted function of each name (liftedFnNamed), for the * liftedFns indexed so far. liftedFns only grows. */ private readonly liftedFnsByName = new Map(); @@ -4352,6 +4355,7 @@ export class Lowerer { "createRequireBindingTargets", "createRequireElementTargets", "computedGlobalReads", + "namesWebStreamClass", "timeValueDateDecisions", "timeValueDateWrites", "dynamicOverloadAnswers", diff --git a/packages/compiler/src/frontend/lowering/record-inherited.ts b/packages/compiler/src/frontend/lowering/record-inherited.ts index 65d070f1d..ca80a4f4f 100644 --- a/packages/compiler/src/frontend/lowering/record-inherited.ts +++ b/packages/compiler/src/frontend/lowering/record-inherited.ts @@ -499,7 +499,8 @@ function builtinAnswer( type: BOOL, loc, }); - if (arg === undefined || !mayBeSymbol(arg.type)) return has(propertyKeyOf(lowerer, arg, loc)); + if (arg === undefined || !mayBeSymbol(lowerer, arg.type)) + return has(propertyKeyOf(lowerer, arg, loc)); const id = declare("%protoKey", DYN); const held = varRef(id, DYN, loc); return { @@ -520,13 +521,16 @@ function builtinAnswer( } } -/** Whether a value of `type` may be a symbol at run time. */ -function mayBeSymbol(type: IrType): boolean { - return !( - isUnitType(type) || - type.kind === "string" || - type.kind === "f64" || - type.kind === "bool" +/** Whether a value of `type` may be a symbol at run time: a checked value + * or a union with a symbol or checked arm. Records, class instances, + * arrays and functions never are, so their keys skip the symbol test (a + * key that converts without running code leaves the method's impl no heap + * writer to ReferenceWrites). */ +function mayBeSymbol(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "dyn" || type.kind === "symbol") return true; + if (type.kind !== "union") return false; + return (lowerer.unions.get(type.unionId)?.arms ?? []).some( + (arm) => arm.kind === "dyn" || arm.kind === "symbol", ); } @@ -540,9 +544,29 @@ function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc): type: STRING, loc, }; - if (arg.type.kind === "string") return arg; - if (arg.type.kind === "f64" || arg.type.kind === "bool") + const type = arg.type; + if (type.kind === "string") return arg; + if (type.kind === "f64" || type.kind === "bool") return { kind: "toString", operand: arg, type: STRING, loc }; + // Unions of scalars convert through the union's own ToString. + if ( + type.kind === "union" && + (lowerer.unions.get(type.unionId)?.arms ?? []).every( + (arm) => + isUnitType(arm) || arm.kind === "string" || arm.kind === "f64" || arm.kind === "bool", + ) + ) + return { kind: "toString", operand: arg, type: STRING, loc }; + // A record or class instance that answers Object.prototype.toString + // spells "[object Object]" without running any code. + if ( + (type.kind === "record" && defaultRecordToString(lowerer, type.shapeId)) || + (type.kind === "object" && defaultToString(lowerer, type.className)) + ) + return { kind: "strLit", value: "[object Object]", type: STRING, loc }; + // Any other key (an array, a function, a record or class instance with + // its own toString, a checked value) converts as ToPropertyKey does, + // through the dynamic tier: the conversion may run the key's code. return { kind: "libCall", fn: "dyn.toStringCoerce", @@ -552,6 +576,29 @@ function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc): }; } +/** A record shape whose values answer Object.prototype.toString: no + * `toString` field, and no index signature that may hold one at run time + * (or give the record a null prototype). */ +function defaultRecordToString(lowerer: Lowerer, shapeId: string): boolean { + const shape = lowerer.shapes.get(shapeId); + return ( + shape !== undefined && + shape.indexValue === undefined && + !shape.fields.some((field) => field.name === "toString") + ); +} + +/** A source class whose instances answer Object.prototype.toString. */ +function defaultToString(lowerer: Lowerer, className: string): boolean { + const info = lowerer.classes.get(className); + return ( + info !== undefined && + info.decl !== undefined && + lowerer.findMethodOn(info, "toString") === null && + !lowerer.overrideBelow(info, "toString") + ); +} + /** `value` returned as `ret`: as is, wrapped into a union arm or a checked * value, discarded for void, or the checked-conversion TypeError. */ function returnAs(lowerer: Lowerer, value: IrExpr, ret: IrType, loc: SrcLoc): IrStmt[] { diff --git a/packages/compiler/src/frontend/lowering/unit-journal.test.ts b/packages/compiler/src/frontend/lowering/unit-journal.test.ts index f92f11dc7..3b9599d7b 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.test.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.test.ts @@ -147,20 +147,27 @@ test("a map table hands over the entries added after a size", () => { expect([...map]).toEqual([["a", "%h.1"]]); }); -test("a held table notices a replaced entry", () => { +test("a table's content snapshot notices changed, reordered and removed entries", () => { const map = new Map([["a", "%h.1"]]); - const held = mapTable("helpers", map).hold!(); + const held = mapTable("helpers", map).contents!(); map.set("b", "%h.2"); - expect(held.same()).toBe(true); + expect(held.changed()).toBeNull(); map.set("a", "%h.9"); - expect(held.same()).toBe(false); - const set = new Set(["x"]); - const heldSet = setTable("names", set).hold!(); + expect(held.changed()).toBe("a"); + // A unit names the helper it got, not the key it was looked up by. + expect(held.changedAmong(new Set(["%h.1"]))).toBe("a"); + expect(held.changedAmong(new Set(["%h.2"]))).toBeNull(); + held.refresh(new Set(["%h.1"])); + expect(held.changed()).toBeNull(); + const set = new Set(["x", "y"]); + const heldSet = setTable("names", set).contents!(); set.delete("x"); set.add("x"); - expect(heldSet.same()).toBe(true); + expect(heldSet.changed()).toBe("x"); set.delete("x"); - expect(heldSet.same()).toBe(false); + expect(heldSet.changedAmong(new Set(["x"]))).toBe("x"); + heldSet.refresh(new Set(["x"])); + expect(heldSet.changed()).toBeNull(); }); test("a function table keeps the function an earlier unit made", () => { @@ -280,7 +287,7 @@ test("a unit that changes an entry it did not add has no record", () => { state.helpers.set("shared", "%h.first"); const unit = recordUnit(subject, "a"); expect(unit.record).toBeNull(); - expect(unit.reasons).toEqual(["helpers:replaced"]); + expect(unit.reasons).toEqual(["helpers:changed"]); }); test("a recorded wave throws when a unit changes a held function in place", () => { diff --git a/packages/compiler/src/frontend/lowering/unit-journal.ts b/packages/compiler/src/frontend/lowering/unit-journal.ts index 991b1368c..e349bb2cf 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.ts @@ -96,8 +96,6 @@ export interface JournalTable { readonly digest?: (key: string, before: number) => string | null; /** Removes the entries from position `from` on. */ readonly truncate?: (from: number) => void; - /** The entries the table holds now. */ - readonly hold?: () => HeldEntries; /** The content of the entries the table holds now, for tables whose * values are objects a unit could change in place. */ readonly contents?: () => HeldContents; @@ -127,10 +125,13 @@ export function contentDigest(text: string): string { return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`; } -/** The strings quoted in JSON `texts`, unescaped only as far as their - * delimiting quotes: the names a unit's encoded IR and entries refer to. */ +/** The names quoted in JSON `texts`, unescaped only as far as their + * delimiting quotes: the strings that start with `%` (helper, lifted and + * IR data names) and union ids (`u` and 12 hex digits), which the tables + * a unit reads and changes are keyed by. */ export function quotedStrings(texts: readonly string[]): Set { const out = new Set(); + const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102); for (const text of texts) { let start = -1; for (let index = 0; index < text.length; index++) { @@ -139,7 +140,13 @@ export function quotedStrings(texts: readonly string[]): Set { else if (char === 34 /* " */) { if (start < 0) start = index + 1; else { - out.add(text.slice(start, index)); + const first = text.charCodeAt(start); + let name = first === 37; /* % */ + if (!name && first === 117 /* u */ && index - start === 13) { + name = true; + for (let at = start + 1; at < index && name; at++) name = hex(text.charCodeAt(at)); + } + if (name) out.add(text.slice(start, index)); start = -1; } } @@ -148,12 +155,6 @@ export function quotedStrings(texts: readonly string[]): Set { return out; } -/** A table's entries at one point (JournalTable.hold). */ -export interface HeldEntries { - /** Whether the table's first entries are still these keys and values. */ - same(): boolean; -} - /** A field a unit's lowering must leave as it found it (fixedScalar), or a * flag it may set, which applying its record sets too (flagScalar). */ export interface JournalScalar { @@ -288,7 +289,11 @@ export function recordUnit( ): RecordedUnit { const tables = host.tables; const before = tables.map((table) => table.size()); - const held = tables.map((table) => (table.hold === undefined ? null : table.hold())); + // Outside a recorded wave, the unit's own snapshot of the tables' content. + const own = + contents === null + ? tables.map((table) => (table.contents === undefined ? null : table.contents())) + : null; const marks = host.scalars.map((scalar) => scalar.mark()); const counts = host.statementCounts(); const recorder = new UnitEvents(); @@ -296,8 +301,6 @@ export function recordUnit( const reasons: string[] = []; const grown: number[] = []; tables.forEach((table, index) => { - const check = held[index] ?? null; - if (check !== null && !check.same()) reasons.push(`${table.name}:replaced`); if (table.size() === before[index]) return; if (table.entries === undefined) reasons.push(table.name); else grown.push(index); @@ -339,7 +342,12 @@ export function recordUnit( if (entries !== null) for (const entry of entries) texts.push(entry); } const names = quotedStrings(texts); - if (contents !== null) { + if (own !== null) { + if (reasons.length === 0) + tables.forEach((table, index) => { + if ((own[index] ?? null)?.changed() != null) reasons.push(`${table.name}:changed`); + }); + } else if (contents !== null) { // A unit with a record must leave the entries it names as they were; a // unit without one keeps its changes. if (reasons.length === 0) @@ -419,6 +427,69 @@ export function lowerWaveRecorded( }); } +/** The content of a keyed table's entries (JournalTable.contents): each + * entry's encoded value, and the order of the keys, since a table only adds + * entries at the end. An entry is found by its key, or by the name its value + * holds (`valueName`): a unit names the helper it got, not the key it was + * looked up by. `text` answers undefined for a key the table does not + * hold. */ +function keyedContents( + size: () => number, + keys: () => Iterable, + text: (key: string) => string | null | undefined, + valueName: (key: string) => string | null, +): HeldContents { + const texts = new Map(); + let order: string[] = []; + const byName = new Map(); + const differs = (key: string): boolean => text(key) !== texts.get(key); + const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name)); + const held: HeldContents = { + extend: () => { + if (texts.size >= size()) return; + for (const key of keys()) { + if (texts.has(key)) continue; + texts.set(key, text(key) ?? null); + order.push(key); + const named = valueName(key); + if (named !== null) byName.set(named, key); + } + }, + changed: () => { + let index = 0; + for (const key of keys()) { + if (index >= order.length) break; + if (key !== order[index]) return order[index]!; + index++; + } + if (index < order.length) return order[index]!; + for (const key of order) if (differs(key)) return key; + return null; + }, + changedAmong: (names) => { + for (const name of names) { + const key = keyOf(name); + if (key !== undefined && differs(key)) return key; + } + return null; + }, + refresh: (names) => { + for (const name of names) { + const key = keyOf(name); + if (key === undefined) continue; + const now = text(key); + if (now !== undefined) texts.set(key, now); + else { + texts.delete(key); + order = order.filter((held) => held !== key); + } + } + }, + }; + held.extend(); + return held; +} + /** An appendable table over a Map from strings to strings, such as helper * names by key. */ export function mapTable(name: string, map: Map): JournalTable { @@ -427,6 +498,7 @@ export function mapTable(name: string, map: Map): JournalTable { map, (value) => JSON.stringify(value), (text) => JSON.parse(text) as string, + (value) => value, ); } @@ -444,42 +516,15 @@ export function countTable(name: string, map: Map): JournalTable * JSON. JSON writes a number that is not finite as null, so a value whose * encoding holds a null does not encode. */ export function irDataTable(name: string, map: Map): JournalTable { - const encode = (value: V): string | null => { - const text = JSON.stringify(value); - return /[:,[]null\b/.test(text) ? null : text; - }; - return { - ...codecTable(name, map, encode, (text) => JSON.parse(text) as V), - contents: () => { - const texts = new Map(); - const differs = (key: string): boolean => { - const value = map.get(key); - return value === undefined || encode(value) !== texts.get(key); - }; - const held: HeldContents = { - extend: () => { - if (texts.size === map.size) return; - for (const [key, value] of map) if (!texts.has(key)) texts.set(key, encode(value)); - }, - changed: () => { - for (const key of texts.keys()) if (differs(key)) return key; - return null; - }, - changedAmong: (keys) => { - for (const key of keys) if (texts.has(key) && differs(key)) return key; - return null; - }, - refresh: (keys) => { - for (const key of keys) { - const value = map.get(key); - if (texts.has(key) && value !== undefined) texts.set(key, encode(value)); - } - }, - }; - held.extend(); - return held; + return codecTable( + name, + map, + (value) => { + const text = JSON.stringify(value); + return /[:,[]null\b/.test(text) ? null : text; }, - }; + (text) => JSON.parse(text) as V, + ); } /** An appendable table over a Map from strings to values that `encode` @@ -491,6 +536,7 @@ export function codecTable( map: Map, encode: (value: V) => string | null, decode: (text: string) => V, + nameOf: ((value: V) => string) | null = null, ): JournalTable { return { name, @@ -517,21 +563,19 @@ export function codecTable( const keys = [...map.keys()].slice(from); for (const key of keys) map.delete(key); }, - hold: () => { - const keys = [...map.keys()]; - const values = [...map.values()]; - return { - same: () => { - let index = 0; - for (const [key, value] of map) { - if (index >= keys.length) break; - if (key !== keys[index] || value !== values[index]) return false; - index++; - } - return index === keys.length; + contents: () => + keyedContents( + () => map.size, + () => map.keys(), + (key) => { + const value = map.get(key); + return value === undefined ? undefined : encode(value); }, - }; - }, + (key) => { + const value = map.get(key); + return value === undefined || nameOf === null ? null : nameOf(value); + }, + ), }; } @@ -548,20 +592,13 @@ export function setTable(name: string, set: Set): JournalTable { const entries = [...set].slice(from); for (const entry of entries) set.delete(entry); }, - hold: () => { - const entries = [...set]; - return { - same: () => { - let index = 0; - for (const entry of set) { - if (index >= entries.length) break; - if (entry !== entries[index]) return false; - index++; - } - return index === entries.length; - }, - }; - }, + contents: () => + keyedContents( + () => set.size, + () => set.keys(), + (key) => (set.has(key) ? "" : undefined), + () => null, + ), }; } @@ -626,10 +663,6 @@ export function functionTable( while (functions.length > from) functions.pop(); changed(); }, - hold: () => { - const held = [...functions]; - return { same: () => held.every((fn, index) => functions[index] === fn) }; - }, contents: () => { const texts: (string | null)[] = []; const indexes = new Map(); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 4580ac240..3f7c3a412 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -324,16 +324,16 @@ export class ShapeRegistry { * writer after all priority candidates have closed. */ private readonly derivedDeclaredOrderFinalizers: (() => void)[] = []; private currentDeclaredOrderPriority: DeclaredOrderPriority = [0, 0]; + /** The declared-order candidates and the attached names the registry + * holds, counted as they are added (journalSizes). */ + private candidateCount = 0; + private extendedCount = 0; /** All interned shapes in first-seen order. */ readonly shapes: IrRecordShape[] = []; /** The sizes of the registry's tables, which only grow while shapes are * interned (unit-journal.ts). */ journalSizes(): number[] { - let candidates = 0; - for (const list of this.declaredOrderCandidates.values()) candidates += list.length; - let extended = 0; - for (const names of this.extendedNames.values()) extended += names.length; return [ this.revision, this.shapes.length, @@ -342,12 +342,12 @@ export class ShapeRegistry { this.keyedNameKeys.size, this.declaredOrderPriority.size, this.declaredOrderCandidates.size, - candidates, + this.candidateCount, this.derivedDeclaredOrderFinalizers.length, this.recIds.size, this.pendingRec.size, this.extendedNames.size, - extended, + this.extendedCount, ]; } /** ts.Types currently being mapped — a BACK-REFERENCE to one of these is @@ -402,6 +402,7 @@ export class ShapeRegistry { private adoptDeclaredOrder(shape: IrRecordShape, declaredOrder: string[] | undefined): void { if (declaredOrder === undefined) return; + this.candidateCount++; const candidates = this.declaredOrderCandidates.get(shape.id); const candidate = { order: declaredOrder, priority: this.currentDeclaredOrderPriority }; if (candidates) candidates.push(candidate); @@ -557,6 +558,7 @@ export class ShapeRegistry { this.byId.set(id, shape); this.shapes.push(shape); if (declaredOrder !== undefined) { + this.candidateCount++; this.declaredOrderCandidates.set(id, [ { order: declaredOrder, priority: this.currentDeclaredOrderPriority }, ]); @@ -619,6 +621,7 @@ export class ShapeRegistry { shape.fields = [...shape.fields, ...fresh].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); + this.extendedCount += fresh.length; this.extendedNames.set(shapeId, [ ...(this.extendedNames.get(shapeId) ?? []), ...fresh.map((f) => f.name), @@ -746,10 +749,6 @@ export class UnionRegistry { this.keyedNameKeys.delete(def.id); } }, - hold: () => { - const held = [...this.unions]; - return { same: () => held.every((def, index) => this.unions[index] === def) }; - }, contents: () => { // A recursive placeholder still pending fills in place when its // outer frame completes, which changes journalSizes. diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c21e8b369..be512a754 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -796,7 +796,9 @@ async function emitNativeProgramObject( // An optimized program that stays one partition is optimized whole and // generates code for its parts concurrently. const codegenParts = single || opts.optimization === "dev" ? 1 : nativeCodegenParts(llvmBytes); - const partitions = single ? 1 : Math.max(nativeProgramPartitions(llvmBytes), codegenParts); + const partitions = single + ? 1 + : Math.max(nativeProgramPartitions(llvmBytes, opts.optimization === "dev"), codegenParts); try { const artifact = await emitNativeArtifact({ outputPath: linkPath, @@ -1086,6 +1088,7 @@ async function prepareExecutableInput( objectAudit: opts.sanitize === true, sanitizeAttributes: opts.sanitize === true, lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), + timing, ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), ...inlineRcOption(opts.optimization, opts.sanitize === true), ...(opts.detachedExit === true ? { detachedExit: true } : {}), @@ -2610,6 +2613,7 @@ async function compileLibraryTracked( wasi: buildPlatform === "wasi", objectAudit: opts.sanitize === true, sanitizeAttributes: opts.sanitize === true, + timing, ...inlineRcOption(profile.optimization, opts.sanitize === true), }); timing("llvm-emit", { diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 45d971a10..04392fe48 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1044,8 +1044,9 @@ const RUNTIME_FEATURE_RULES = { mime: rule({ prefixes: ["mime."] }), /** Web streams in checked-dynamic code (scr_webstreams.c). */ webStreams: rule({ prefixes: ["webstream."] }), - /** AbortController and AbortSignal (scr_abort.c, or the fetch unit). */ - abortSignal: rule({ prefixes: ["abort."] }), + /** AbortController and AbortSignal (scr_abort.c, or the fetch unit). + * A WritableStream controller's signal is one (scr_webstreams.c). */ + abortSignal: rule({ prefixes: ["abort.", "webstream."] }), /** util.debounce and util.throttle (scr_util_timing.c). */ utilTiming: rule({ fns: ["util.debounce", "util.throttle"] }), /** events.once(emitter, name, { signal }) (scr_events_once_signal.c). */ diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index b46778827..fafae4f9d 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -75,6 +75,11 @@ import { splitLlvmLibraryProgram } from "../backend/llvm/split.js"; import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js"; import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js"; import { inputsWithoutLld, lldLinkerArgs } from "../backend/lld.js"; +import { + hugeTextLinkerArgs, + hugeTextProgram, + rewriteHugeTextExecutable, +} from "../backend/huge-text.js"; import { openNativeExecutableCache } from "./executable-cache.js"; import { LinkerJobs } from "./linker-job.js"; import { nativeFileIdentity } from "./file-identity.js"; @@ -381,7 +386,9 @@ export class NativeCompiler { // An optimized program that stays one partition is optimized whole and // generates code for its parts concurrently. const codegenParts = library || optimization === "dev" ? 1 : nativeCodegenParts(size); - const partitions = library ? 1 : Math.max(nativeProgramPartitions(size), codegenParts); + const partitions = library + ? 1 + : Math.max(nativeProgramPartitions(size, optimization === "dev"), codegenParts); return this.emitObject( input, output, @@ -637,11 +644,15 @@ export class NativeCompiler { }); // Linux links with lld when the driver finds it. const lld = options.sanitize ? [] : lldLinkerArgs(target.platform, toolchain.linker); + const hugeText = + !options.sanitize && hugeTextProgram(target, [object, ...programObjects.slice(1)]); + const zig = process.env["SCRIPTC_LINKER"] === undefined && target.defaultLinker === "zig"; const linkMap = join(stage, "link.map"); const mapArgs = staticLinkMapArgs(target, options.staticLink === true, linkMap); const linkArgsWith = (withLld: boolean): string[] => [ ...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs), ...(withLld ? lld : []), + ...hugeTextLinkerArgs(hugeText, (withLld && lld.length !== 0) || zig), ...plan.driverFlags, ...mapArgs, ...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)), @@ -679,6 +690,7 @@ export class NativeCompiler { } timing("native-link"); requireNativeArtifact(stagedOutput); + if (hugeText) rewriteHugeTextExecutable(stagedOutput); if (mapArgs.length !== 0) { const refusal = staticExecutableRefusal(target, true, readFileSync(linkMap, "utf8")); if (refusal !== null) throw new NativeCodegenError("SC3002", refusal); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 33c239c64..db40c220f 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -440,6 +440,7 @@ export function prepareNativeExecutable( lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true), functionWorkers: emitWorkers(), forkPrimitives: emitForkPrimitives(), + timing, ...inlineRcOption(options.optimization, options.sanitize === true), ...(options.detachedExit === true && outputKind === "exe" ? { detachedExit: true } : {}), ...targetCpuField(options.targetCpu), @@ -556,6 +557,7 @@ export function prepareNativeLibrary( wasi: toolchain.target.platform === "wasi", objectAudit: options.sanitize === true, sanitizeAttributes: options.sanitize === true, + timing, ...inlineRcOption(profile.optimization, options.sanitize === true), }); timing("llvm-emit"); diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 914dc81d8..c42bda8b7 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -438,3 +438,36 @@ test("a loop that starts from a typed-array read guards the read's integer and o await rm(dir, { recursive: true, force: true }); } }); + +test("an exact integer loop limit becomes the endpoint without rounding", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-counted-limit-")); + try { + const entry = join(import.meta.dirname, "../../../tests/corpus/counted-loop-integer-limits.ts"); + const outPath = join(dir, "limits.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), {}); + const bodies = (method: string): string[] => + [ + ...ll.matchAll( + new RegExp( + `define internal [^\\n]+ @\\w*Page_${method}(?:\\.i32)?\\([^\\n]*\\) #0 \\{ ; [^\\n]*([\\s\\S]*?)\\n\\}`, + "g", + ), + ), + ].map((m) => m[1]!); + // The int32 rank is the endpoint of the loops in both directions, with + // exclusive and inclusive bounds: no floor, ceil or 2^53 clamp. + for (const method of ["set", "suffix"]) { + const found = bodies(method); + expect(found.length).toBeGreaterThan(0); + for (const body of found) { + expect(body).toContain("integer induction i"); + expect(body).not.toMatch(/@llvm\.(floor|ceil)\.f64/); + expect(body).not.toContain("0x4340000000000000"); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/function-table-dispatch-emission.test.ts b/packages/compiler/test/function-table-dispatch-emission.test.ts new file mode 100644 index 000000000..65e46f18b --- /dev/null +++ b/packages/compiler/test/function-table-dispatch-emission.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import type { IrExpr, IrModule } from "../src/ir/ir.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function emit(source: string): Promise { + const outDir = await mkdtemp(join(tmpdir(), "scriptc-function-table-")); + directories.push(outDir); + const entry = join(outDir, "main.ts"); + await writeFile(entry, source); + const outPath = join(outDir, "program.ll"); + const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result)).toBe(true); + return readFile(outPath, "utf8"); +} + +async function lowerIr(source: string): Promise { + const outDir = await mkdtemp(join(tmpdir(), "scriptc-function-table-")); + directories.push(outDir); + const entry = join(outDir, "main.ts"); + await writeFile(entry, source); + const outPath = join(outDir, "main.ir.json"); + const result = await compile(entry, { outDir, outPath, outputKind: "ir" }); + expect(result.ok, JSON.stringify(result)).toBe(true); + return deserializeModule(await readFile(outPath, "utf8")); +} + +function definition(llvm: string, symbol: string): string { + const match = new RegExp(`^define internal [^\\n]*@${symbol}(?:\\.i32)?\\([^]*?^}`, "m").exec( + llvm, + ); + expect(match, symbol).not.toBeNull(); + return match![0]; +} + +// A record of functions dispatched by key: the hit lends the stored closure +// to the call (no union box, no lookup through the runtime, no closure +// retain or release), and only a miss tests the key against +// Object.prototype's method names. +const source = ` +type Op = (acc: number, arg: number) => number; +const ops: Record = { + add: (acc, arg) => acc + arg, + neg: (acc) => -acc, +}; +function asserted(name: string, acc: number): number { + return ops[name]!(acc, 1); +} +function optional(name: string, acc: number): number { + return ops[name]?.(acc, 1) ?? acc; +} +// Object arguments: the inherited methods' key conversion must not make +// every indirect call a heap writer (which would end all borrowing). +class Point { + constructor(readonly x: number) {} +} +type Measure = (p: Point, q: Point) => number; +const measures: Record = { sum: (p, q) => p.x + q.x }; +function objects(name: string, p: Point): number { + return measures[name]!(p, p); +} +function checked(name: string, acc: number): number { + const handler = ops[name]; + return handler ? handler(acc, 1) : acc; +} +let acc = 1; +for (const name of ["add", "neg", "nope", "toString"]) { + acc = asserted(name === "nope" ? "add" : name === "toString" ? "neg" : name, acc); + acc = optional(name, acc); + acc = checked(name, acc); + acc += objects(name === "nope" ? "sum" : "sum", new Point(acc)); +} +console.log(acc); +`; + +test("function-table dispatch borrows the stored closure on a hit", async () => { + const llvm = await emit(source); + for (const fn of ["asserted", "optional", "checked", "objects"]) { + const body = definition(llvm, `sc_bf_${fn}`); + expect(body, fn).not.toMatch(/scr_union_new_ref|scr_map_get_str_ref/); + expect(body, fn).not.toMatch(/scr_closure_retain|sc_nretain_func/); + expect(body, fn).toMatch(/sc_map_entry_str/); + } +}); + +// A borrowed ternary whose arms are fallback reads: a miss binds the +// inherited method into a frame-owned slot, which must outlive the arm +// until the call has used the lent closure. The lowering builds no such +// ternary today (a bound read is a call's own callee), so the test wraps +// the callee read of `pick` in one. +test("a borrowed ternary of fallback reads keeps a bound default past its arm", async () => { + const mod = await lowerIr(` +type Op = (x: string) => string; +const table: Record = { echo: (x) => x }; +function pick(name: string, flag: boolean): string { + return table[name]!("echo"); +} +console.log(pick(process.argv[2] ?? "hasOwnProperty", process.argv.length > 3)); +`); + const pick = mod.functions.find((fn) => fn.name === "pick")!; + const flag = pick.params[1]!; + let wrapped = 0; + const visit = (node: unknown): void => { + if (node === null || typeof node !== "object") return; + for (const [key, value] of Object.entries(node)) { + const e = value as IrExpr | null; + if (e !== null && typeof e === "object" && e.kind === "nullish" && e.right.kind === "call") { + const cond: IrExpr = { kind: "varRef", localId: flag.localId, type: flag.type, loc: e.loc }; + (node as Record)[key] = { + kind: "ternary", + cond, + then: e, + else_: structuredClone(e), + type: e.type, + loc: e.loc, + }; + wrapped++; + } else visit(value); + } + }; + visit(pick.body); + expect(wrapped).toBe(1); + const body = definition(emitLlvmModule(mod), "sc_bf_pick"); + // Each arm lends its read and leaves: nothing it filled is released + // before the join. + const arms = [...body.matchAll(/^read\.(?:then|else)\d+:\n([^]*?)(?=^read\.)/gm)].map( + (m) => m[1]!, + ); + expect(arms).toHaveLength(2); + for (const arm of arms) + expect(arm).toMatch(/store ptr %t\d+, ptr %s\d+\n {2}br label %read\.end\d+\n$/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-timer-refresh/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-timer-refresh/main.ts.json new file mode 100644 index 000000000..2da15efc8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6890-node-island-timer-refresh/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6890-node-island-timer-refresh/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8363-record-inherited-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8363-record-inherited-keys.ts.json new file mode 100644 index 000000000..80fc1f5e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8363-record-inherited-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8363-record-inherited-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8364-record-nullish-receiver-arm/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8364-record-nullish-receiver-arm/main.ts.json new file mode 100644 index 000000000..df77c76ff --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8364-record-nullish-receiver-arm/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8364-record-nullish-receiver-arm/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8408-fetch-connection-reuse.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8408-fetch-connection-reuse.ts.json new file mode 100644 index 000000000..0a8304063 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8408-fetch-connection-reuse.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8408-fetch-connection-reuse.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8409-fetch-compressed-read-after-end.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8409-fetch-compressed-read-after-end.ts.json new file mode 100644 index 000000000..2237bda85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8409-fetch-compressed-read-after-end.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8409-fetch-compressed-read-after-end.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json new file mode 100644 index 000000000..2f219c543 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/counted-loop-integer-limits.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json new file mode 100644 index 000000000..6e10810ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pad-case-converted-strings.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-transform-pipes-untyped.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-transform-pipes-untyped.mjs.json new file mode 100644 index 000000000..d1041347b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-transform-pipes-untyped.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/web-stream-transform-pipes-untyped.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-writable-untyped.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-writable-untyped.mjs.json new file mode 100644 index 000000000..42be7ae57 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-writable-untyped.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/web-stream-writable-untyped.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index a48872221..9d07412c0 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -4868,6 +4868,19 @@ static void scr_hosted_timer_drop(uint32_t token) { scr_closure_release(cb); } +/* refresh(): re-arm to now + the delay the timer was created with, Node's + * Timeout.refresh(). A migrated timer's Node handle was created with its + * remaining delay at handoff, and a due one has no handle yet, so the + * handle is replaced rather than refreshed. A firing timer's flag makes the + * run loop keep the re-armed handle when its callback returns. */ +static void scr_hosted_timer_rearm(uint32_t token) { + ScrHJob *j = &scr_hjobs[token - 1]; + if (j->handle != 0) scr_loop_hosted->timer_clear(j->handle); + j->due = false; + j->handle = scr_loop_hosted->timer(scr_hosted_delay(j->delay_ms), j->repeat_ms, + j->reffed ? 1 : 0, token); +} + static bool scr_hosted_timer_op(unsigned long id, int op) { uint32_t token = scr_hmap_token(id * 2); ScrHJob *j = scr_hjob(token, SCR_HJ_TIMER); @@ -4889,7 +4902,7 @@ static bool scr_hosted_timer_op(unsigned long id, int op) { return true; case SCR_HOSTED_REFRESH: if (j->firing) j->refreshed = true; - if (j->handle != 0) scr_loop_hosted->timer_refresh(j->handle); + scr_hosted_timer_rearm(token); return true; default: return j->reffed; diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 6e5d9856e..1d8febbc3 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -1551,8 +1551,11 @@ static ScrDyn *scr_dyn_then_of(const ScrDyn *value, const ScrDyn *reader) { return NULL; } -/* Resolves `target` with `value` (borrowed). */ -static void scr_dyn_resolve_value(ScrPromise *target, ScrDyn *value, const ScrDyn *reader) { +/* Resolves `target` with `value` (borrowed). Inlined into both callers, so + * the code of scr_dyn_then_resolve_call does not depend on whether a + * program links scr_dyn_promise_resolve_with. */ +__attribute__((always_inline)) static inline void scr_dyn_resolve_value(ScrPromise *target, ScrDyn *value, + const ScrDyn *reader) { if (value->kind == SCR_DYN_PROMISE) { scr_promise_resolve_dyn(target, scr_dyn_retain(value)); return; @@ -1669,6 +1672,10 @@ ScrPromise *scr_dyn_promise_resolve(const ScrDyn *value, const ScrDyn *reader) { return scr_dyn_promise_entry(NULL, value, reader); } +void scr_dyn_promise_resolve_with(ScrPromise *target, ScrDyn *value) { + scr_dyn_resolve_value(target, value, NULL); +} + static void scr_dyn_settled_store(ScrArr *a, double i, ScrPromise *src) { ScrDyn *record = scr_dyn_new_obj(); bool fulfilled = !scr_promise_is_rejected(src); diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 9a93fc70b..fafa16eda 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -245,7 +245,14 @@ typedef enum { Y(WS_COUNT_STRATEGY) /* CountQueuingStrategy (scr_webstreams.c) */ \ Y(WS_RCONTROLLER) /* ReadableStreamDefaultController (scr_webstreams.c) */ \ Y(WS_READABLE) /* ReadableStream for checked-dynamic code (scr_webstreams.c) */ \ - Y(WS_READER) /* ReadableStreamDefaultReader (scr_webstreams.c) */ + Y(WS_READER) /* ReadableStreamDefaultReader (scr_webstreams.c) */ \ + Y(WS_TCONTROLLER) /* TransformStreamDefaultController (scr_webstreams.c) */ \ + Y(WS_TEXT_DECODER) /* TextDecoderStream (scr_webstreams.c) */ \ + Y(WS_TEXT_ENCODER) /* TextEncoderStream (scr_webstreams.c) */ \ + Y(WS_TRANSFORM) /* TransformStream for checked-dynamic code (scr_webstreams.c) */ \ + Y(WS_WCONTROLLER) /* WritableStreamDefaultController (scr_webstreams.c) */ \ + Y(WS_WRITABLE) /* WritableStream for checked-dynamic code (scr_webstreams.c) */ \ + Y(WS_WRITER) /* WritableStreamDefaultWriter (scr_webstreams.c) */ /* Values below the largest pinned one that no tag holds, each with the * reason. Never reuse one without removing it here. */ @@ -2055,6 +2062,12 @@ typedef struct ScrDynHandleClass { /* The prototype's methods and getters in definition order (the order * Object.keys reports); NULL = the methods, then the getters. */ const char *const *order; + /* A call of one of the class's method values + * (`WritableStream.prototype.close.call(stream)`): the class's method, + * which an own property of the same name on the handle does not + * replace. NULL = ScrDynHandleOps.invoke. */ + ScrDyn *(*method_invoke)(void *h, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what); } ScrDynHandleClass; /* Installs a tag's ops with the class its handles belong to; both outlive @@ -2081,6 +2094,11 @@ ScrDyn *scr_dyn_handle_class_prototype_of(const ScrDyn *handle); * receiver (+1); NULL when the chain has none or with the exception * pending. */ ScrDyn *scr_dyn_handle_class_member(ScrDynHandleTag tag, void *h, const char *key, size_t len); +/* A method value's call on a handle of its tag (`self`, borrowed): the + * class's method_invoke, or the ops' invoke for a tag without one. +1, or + * NULL with the exception pending. */ +ScrDyn *scr_dyn_handle_class_method_call(const ScrDynHandleOps *ops, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc); /* The operations a Transform handle gives the stream unit (scr_stream.c): * the slot where the handle keeps its stream (+1) with the stream's @@ -2157,6 +2175,11 @@ ScrPromise *scr_dyn_promise_any(const ScrDyn *entries, const ScrDyn *reader); * itself, a thenable adopts through its resolve job, a throwing `then` * read rejects, and any other value fulfills. +1. */ ScrPromise *scr_dyn_promise_resolve(const ScrDyn *value, const ScrDyn *reader); +/* Resolves `target` with a dyn value (borrowed), as a promise's resolve + * function does: a native promise adopts, a thenable adopts through its + * resolve job, a throwing `then` read rejects, and any other value + * fulfills. */ +void scr_dyn_promise_resolve_with(ScrPromise *target, ScrDyn *value); /* Native predicates for typed reference capsules and boxed errors. */ bool scr_dyn_ref_typeof_is(const ScrDyn *d, const char *name); diff --git a/packages/runtime/src/scr_dyn_handle_class.c b/packages/runtime/src/scr_dyn_handle_class.c index 42e2884c5..cf2a04f76 100644 --- a/packages/runtime/src/scr_dyn_handle_class.c +++ b/packages/runtime/src/scr_dyn_handle_class.c @@ -183,6 +183,14 @@ ScrDyn *scr_dyn_handle_class_member(ScrDynHandleTag tag, void *h, const char *ke return value; } +ScrDyn *scr_dyn_handle_class_method_call(const ScrDynHandleOps *ops, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc) { + const ScrDynHandleClass *klass = hc_classes[self->v.handle.tag]; + if (klass && klass->method_invoke) + return klass->method_invoke(self->v.handle.ptr, self, method, args, argc, method); + return ops->invoke(self->v.handle.ptr, self, method, args, argc, method); +} + static void hc_define_method(ScrDyn *prototype, ScrDynHandleTag tag, const char *name) { ScrDyn *fn = scr_dyn_handle_class_method(tag, name); if (fn) hc_define(prototype, name, fn, true, true, true); diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 4416d99a9..c946b74be 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -1140,15 +1140,19 @@ static void sf_response_pause_if_full(SfStream *s) { static void sf_response_resume_if_ready(SfStream *s) { SfTransfer *t = s->response_owner; - if (!t || t->done || !t->response_paused || !t->response_socket || - s->queued >= 1 || s->close_requested || s->closed || s->error) { + if (!t || t->done || s->queued >= 1 || s->close_requested || s->closed || + s->error || t->inflate_draining) { return; } - if (t->inflate_draining) return; - if (t->inflating && (t->inflate_pending || t->inflate_needs_drain)) { + /* Compressed input or decoder output still buffered drains on demand: + * while the socket is paused, and after 'end', when the transfer no + * longer holds the socket and no more data arrives. */ + if (t->inflating && (t->inflate_pending || t->inflate_needs_drain) && + (t->response_paused || t->response_ended)) { sf_inflate_resume(t); return; } + if (!t->response_paused || !t->response_socket) return; t->response_paused = false; scr_net_sock_release(scr_net_sock_resume(t->response_socket)); } @@ -5538,9 +5542,21 @@ static void sf_on_data(ScrClosure *cb, ScrBytes *chunk) { sf_release(t); } +/* After 'end' the keep-alive agent can hand the socket to the next request + * on the connection, so the transfer leaves it reading and stops pausing + * and resuming it. */ +static void sf_drop_response_socket(SfTransfer *t) { + if (!t->response_socket) return; + if (t->response_paused) scr_net_sock_release(scr_net_sock_resume(t->response_socket)); + t->response_paused = false; + scr_net_sock_release(t->response_socket); + t->response_socket = NULL; +} + static void sf_on_end(ScrClosure *cb) { SfTransfer *t = sf_from(cb); if (!t) return; + sf_drop_response_socket(t); if (!t->done) { if (t->inflating && t->response_stream && !t->response_stream->discarded) { @@ -6081,9 +6097,16 @@ static bool sf_start_hop(SfTransfer *t) { wrap_ctx = scr_tls_fetch_client_ctx(sni, true); wrap = &scr_tls_fetch_client_wrap; } - ScrHttpClientReq *client = scr_http_request_ex( - dial, dial_port, path, t->method, 0, headers, false, NULL, NULL, - default_port, wrap, wrap_ctx); + /* A direct connection goes through fetch's keep-alive agent, which hands + * an idle connection to the next request for the same origin, as + * undici's dispatcher does; the bare host keys the pool, so origins that + * share an address keep their own connections. A proxied request dials + * its own connection. */ + ScrHttpClientReq *client = + proxy ? scr_http_request_ex(dial, dial_port, path, t->method, 0, headers, false, NULL, + NULL, default_port, wrap, wrap_ctx) + : scr_http_fetch_request(https, dial, dial_port, path, t->method, headers, + default_port, wrap, wrap_ctx, bare); scr_arr_release(headers); scr_str_release(path); scr_str_release(dial); diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 0284c31c4..b006d9c36 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -5286,6 +5286,9 @@ typedef struct ScrHttpAgent { double max_sockets; /* INFINITY = Node's default */ double max_free; double timeout_ms; /* < 0 = unset */ + /* without a timeout, how long an idle pooled socket stays open (0 = until + * the server closes it); fetch's agent keeps undici's 4 s */ + double idle_ms; double default_port; /* settable (agent.defaultPort = p) */ bool destroyed; ScrHttpAgentEnt *ents; /* append order — Node's FIFO queue */ @@ -5519,7 +5522,7 @@ static bool scr_http_client_pool(struct ScrHttpConn *conn, struct ScrHttpClientR scr_str_release(name); return false; } - double timeout = ag->timeout_ms > 0 ? ag->timeout_ms : 0; + double timeout = ag->timeout_ms > 0 ? ag->timeout_ms : ag->idle_ms; if (hint >= 0) { double server = hint - 1000; if (server < 0) { @@ -5606,20 +5609,12 @@ static ScrStr *scr_http_agent_name(const char *host, size_t host_len, const char return scr_jb_finish(&b); } -ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, - double max_sockets, double max_free, double timeout_ms, - double port /* < 0 = unset */, const ScrDyn *scheduling) { +/* A registered agent (+1), the half of new http.Agent(opts) after option + * validation. */ +static ScrHttpAgent *scr_http_agent_alloc(bool secure, bool keep_alive, double ka_msecs, + double max_sockets, double max_free, double timeout_ms, + double port, bool fifo) { scr_http_install(); - /* Node: options.scheduling || 'lifo', then validateOneOf */ - bool fifo = false; - if (scheduling != NULL && scr_dyn_truthy(scheduling)) { - const ScrStr *s = scheduling->kind == SCR_DYN_STR ? scheduling->v.str : NULL; - if (s != NULL && s->len == 4 && memcmp(s->data, "fifo", 4) == 0) fifo = true; - else if (s == NULL || s->len != 4 || memcmp(s->data, "lifo", 4) != 0) { - scr_dyn_arg_value_fail("scheduling", "must be one of: 'fifo', 'lifo'", scheduling); - return NULL; - } - } ScrHttpAgent *a = calloc(1, sizeof *a); if (!a) scr_http_oom(); a->rc = 1; @@ -5642,6 +5637,25 @@ ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, a->in_registry = true; a->next = scr_http_agents; scr_http_agents = scr_http_agent_retain(a); + return a; +} + +ScrDyn *scr_http_agent_new(bool secure, bool keep_alive, double ka_msecs, + double max_sockets, double max_free, double timeout_ms, + double port /* < 0 = unset */, const ScrDyn *scheduling) { + scr_http_install(); + /* Node: options.scheduling || 'lifo', then validateOneOf */ + bool fifo = false; + if (scheduling != NULL && scr_dyn_truthy(scheduling)) { + const ScrStr *s = scheduling->kind == SCR_DYN_STR ? scheduling->v.str : NULL; + if (s != NULL && s->len == 4 && memcmp(s->data, "fifo", 4) == 0) fifo = true; + else if (s == NULL || s->len != 4 || memcmp(s->data, "lifo", 4) != 0) { + scr_dyn_arg_value_fail("scheduling", "must be one of: 'fifo', 'lifo'", scheduling); + return NULL; + } + } + ScrHttpAgent *a = scr_http_agent_alloc(secure, keep_alive, ka_msecs, max_sockets, max_free, + timeout_ms, port, fifo); ScrDyn *d = scr_dyn_new_handle(a, SCR_DYNH_HTTP_AGENT); scr_http_agent_release(a); /* the handle's retain holds it */ return d; @@ -5703,6 +5717,59 @@ static SCR_TL ScrStr *scr_http_next_socket_path = NULL; extern SCR_TL ScrNetSocket *(*scr_http_socket_dialer)(ScrStr *path, bool deferred); SCR_TL ScrNetSocket *(*scr_http_socket_dialer)(ScrStr *path, bool deferred) = NULL; +/* A request through agent `ag`: an idle pooled socket for the request's + * name (and pool_key) serves it, a free slot dials, and otherwise it + * queues. socket_path (borrowed, nullable) dials a Unix socket. */ +static ScrHttpClientReq *scr_http_request_pooled(ScrHttpAgent *ag, ScrStr *host /*borrowed*/, + double port, ScrStr *path /*borrowed*/, + ScrStr *method /*borrowed*/, double timeout_ms, + ScrArr *header_pairs /*borrowed*/, bool auto_end, + ScrClosure *cb /*moves, nullable*/, + ScrHttpRespFn fn, int default_port, + void (*wrap)(ScrNetSocket *, void *), + void *wrap_ctx, ScrStr *pool_key /*borrowed*/, + ScrStr *socket_path /*borrowed*/) { + double p = port >= 0 ? port + : ag->default_port > 0 ? ag->default_port + : (double)default_port; + char portbuf[16]; + int portn = snprintf(portbuf, sizeof portbuf, "%d", (int)p); + ScrStr *name = scr_http_agent_name(host->data, host->len, portbuf, (size_t)portn, + NULL, 0, 0, socket_path ? socket_path->data : NULL, + socket_path ? socket_path->len : 0); + /* a free pooled socket for the name serves the request right away */ + ScrHttpAgentFree *reuse = ag->keep_alive ? scr_http_agent_free_take(ag, name, pool_key) : NULL; + bool queue = reuse == NULL && scr_http_agent_active(ag, name) >= ag->max_sockets; + ScrNetSocket *presock = socket_path != NULL ? scr_http_socket_dialer(socket_path, queue || reuse) + : queue || reuse ? scr_net_connect_deferred(p, host) + : NULL; + ScrHttpClientReq *c = scr_http_request_impl(host, p, path, method, timeout_ms, header_pairs, + auto_end, cb, fn, default_port, wrap, wrap_ctx, + presock); + if (c == NULL) { /* the method-token throw: nothing registered */ + if (reuse != NULL) scr_http_agent_free_untake(ag, reuse); /* back to the list it came from */ + scr_str_release(name); + return NULL; + } + c->pool_key = pool_key ? scr_str_retain(pool_key) : NULL; + if (ag->timeout_ms >= 0 && timeout_ms <= 0) { + c->timeout_ms = ag->timeout_ms; + scr_net_sock_set_timeout(c->sock, ag->timeout_ms); + } + if (reuse != NULL) scr_http_client_rebind(c, reuse, true); + else if (!queue) scr_http_conn_count(c->conn, ag->sock_count); /* dialed now */ + c->agent = scr_http_agent_retain(ag); + ScrHttpAgentEnt *e = calloc(1, sizeof *e); + if (!e) scr_http_oom(); + e->name = name; /* ownership moves */ + e->client = scr_http_client_retain(c); + e->queued = queue; + ScrHttpAgentEnt **link = &ag->ents; + while (*link) link = &(*link)->next; + *link = e; + return c; +} + ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, @@ -5754,46 +5821,29 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po scr_dyn_arg_type_fail("options.agent", "an instance of http.Agent, false, null, or undefined", agent); return NULL; } - ScrHttpAgent *ag = (ScrHttpAgent *)agent->v.handle.ptr; - double p = port >= 0 ? port - : ag->default_port > 0 ? ag->default_port - : (double)default_port; - char portbuf[16]; - int portn = snprintf(portbuf, sizeof portbuf, "%d", (int)p); - ScrStr *name = scr_http_agent_name(host->data, host->len, portbuf, (size_t)portn, - NULL, 0, 0, socket_path ? socket_path->data : NULL, - socket_path ? socket_path->len : 0); - /* a free pooled socket for the name serves the request right away */ - ScrHttpAgentFree *reuse = ag->keep_alive ? scr_http_agent_free_take(ag, name, pool_key) : NULL; - bool queue = reuse == NULL && scr_http_agent_active(ag, name) >= ag->max_sockets; - ScrNetSocket *presock = socket_path != NULL ? scr_http_socket_dialer(socket_path, queue || reuse) - : queue || reuse ? scr_net_connect_deferred(p, host) - : NULL; - ScrHttpClientReq *c = scr_http_request_impl(host, p, path, method, timeout_ms, header_pairs, - auto_end, cb, fn, default_port, wrap, wrap_ctx, - presock); - if (c == NULL) { /* the method-token throw: nothing registered */ - if (reuse != NULL) scr_http_agent_free_untake(ag, reuse); /* back to the list it came from */ - scr_str_release(name); - return NULL; + return scr_http_request_pooled((ScrHttpAgent *)agent->v.handle.ptr, host, port, path, method, + timeout_ms, header_pairs, auto_end, cb, fn, default_port, wrap, + wrap_ctx, pool_key, socket_path); +} + +/* The agents fetch() dispatches through, like undici's global dispatcher: + * keep-alive connections per origin with no socket limit and no timeout on + * a request in flight; an idle connection closes after 4 s, or the server's + * Keep-Alive hint less one second when that is shorter. */ +static SCR_TL ScrHttpAgent *scr_http_fetch_agents[2]; + +ScrHttpClientReq *scr_http_fetch_request(bool secure, ScrStr *host /*borrowed*/, double port, + ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, + ScrArr *header_pairs /*borrowed*/, int default_port, + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed*/) { + ScrHttpAgent **slot = &scr_http_fetch_agents[secure ? 1 : 0]; + if (*slot == NULL) { + *slot = scr_http_agent_alloc(secure, true, -1, -1, -1, -1, -1, false); + (*slot)->idle_ms = 4000; } - c->pool_key = pool_key ? scr_str_retain(pool_key) : NULL; - if (ag->timeout_ms >= 0 && timeout_ms <= 0) { - c->timeout_ms = ag->timeout_ms; - scr_net_sock_set_timeout(c->sock, ag->timeout_ms); - } - if (reuse != NULL) scr_http_client_rebind(c, reuse, true); - else if (!queue) scr_http_conn_count(c->conn, ag->sock_count); /* dialed now */ - c->agent = scr_http_agent_retain(ag); - ScrHttpAgentEnt *e = calloc(1, sizeof *e); - if (!e) scr_http_oom(); - e->name = name; /* ownership moves */ - e->client = scr_http_client_retain(c); - e->queued = queue; - ScrHttpAgentEnt **link = &ag->ents; - while (*link) link = &(*link)->next; - *link = e; - return c; + return scr_http_request_pooled(*slot, host, port, path, method, 0, header_pairs, false, NULL, NULL, + default_port, wrap, wrap_ctx, pool_key, NULL); } ScrHttpClientReq *scr_http_request_agent(ScrStr *host /*borrowed*/, double port, @@ -5811,6 +5861,8 @@ static void scr_http_agents_cleanup(void) { for (size_t i = 0; i < 2; i++) { scr_dyn_release(scr_http_global_agents[i]); scr_http_global_agents[i] = NULL; + if (scr_http_fetch_agents[i]) scr_http_agent_release(scr_http_fetch_agents[i]); + scr_http_fetch_agents[i] = NULL; } while (scr_http_agents) { ScrHttpAgent *a = scr_http_agents; diff --git a/packages/runtime/src/scr_http.h b/packages/runtime/src/scr_http.h index 654362c06..c07f12c6a 100644 --- a/packages/runtime/src/scr_http.h +++ b/packages/runtime/src/scr_http.h @@ -365,6 +365,14 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po ScrStr *pool_key /*borrowed, nullable: https TLS options*/); /* +1 */ /* http.globalAgent / https.globalAgent: borrowed, and as a +1 value. */ const ScrDyn *scr_http_global_agent(bool secure); +/* fetch()'s client request for a direct connection: through fetch's own + * keep-alive agent, which reuses an idle connection with the same host, + * port and pool_key (+1). */ +ScrHttpClientReq *scr_http_fetch_request(bool secure, ScrStr *host /*borrowed*/, double port, + ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, + ScrArr *header_pairs /*borrowed*/, int default_port, + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed*/); ScrDyn *scr_http_global_agent_value(bool secure); ScrHttpClientReq *scr_http_request_agent(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f7e1d929d..1177231ee 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -7016,7 +7016,14 @@ static ScrDyn *scr_dynh_method_call(ScrClosure *closure, ScrDyn *const *args, si ScrDyn *self = scr_dyn_this_get(); ScrDyn *result; if (self->kind == SCR_DYN_HANDLE && self->v.handle.tag == tag) { +#ifdef SCR_HANDLE_CLASSES + /* A class's method value reaches the class's method past the handle's + * own properties; this build is the one programs with such classes + * link. */ + result = scr_dyn_handle_class_method_call(ops, self, method->name, args, argc); +#else result = ops->invoke(self->v.handle.ptr, self, method->name, args, argc, method->name); +#endif } else { const char *message = method->invalid_this ? method->invalid_this : ops->invalid_this ? ops->invalid_this diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ad42a5824..e8009d25a 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -2066,6 +2066,48 @@ bool scr_process_has_exit_code(void) { return scr_exit_code_assigned || scr_exit_code_hint_get() != 0; } +#if defined(__linux__) && !defined(SCR_LIB) +#include +#include +/* Text on huge pages (huge-text.ts). An executable with enough program code + * is linked with 2 MiB segment alignment, so each 2 MiB window inside its + * text segment starts at a 2 MiB file offset, and the kernel maps a window + * with one huge page when the page cache holds it as one 2 MiB folio. + * MADV_HUGEPAGE on those windows makes the kernel read a window that is not + * cached as a 2 MiB folio. glibc runs it from .preinit_array, before _init + * and the constructors fault in any of the text; musl, which has no + * .preinit_array, runs it as a constructor. Executables with 4 KiB alignment + * make no call. */ +#define SCR_TEXT_HUGE_PAGE ((uintptr_t)2 << 20) + +static int scr_text_segment(struct dl_phdr_info *info, size_t size, void *data) { + (void)size; + uintptr_t *range = data; + for (size_t i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) *ph = &info->dlpi_phdr[i]; + if (ph->p_type != PT_LOAD || !(ph->p_flags & PF_X) || ph->p_align < SCR_TEXT_HUGE_PAGE) continue; + range[0] = (uintptr_t)info->dlpi_addr + (uintptr_t)ph->p_vaddr; + range[1] = range[0] + (uintptr_t)ph->p_memsz; + break; + } + return 1; /* the first object is the executable */ +} + +static bool scr_text_huge_pages_done; + +__attribute__((constructor)) static void scr_text_huge_pages(void) { + if (scr_text_huge_pages_done) return; + scr_text_huge_pages_done = true; + uintptr_t range[2] = {0, 0}; + (void)dl_iterate_phdr(scr_text_segment, range); + uintptr_t lo = (range[0] + SCR_TEXT_HUGE_PAGE - 1) & ~(SCR_TEXT_HUGE_PAGE - 1); + uintptr_t hi = range[1] & ~(SCR_TEXT_HUGE_PAGE - 1); + if (hi > lo) (void)madvise((void *)lo, hi - lo, MADV_HUGEPAGE); +} +__attribute__((section(".preinit_array"), used)) static void (*const scr_text_huge_pages_entry)(void) = + scr_text_huge_pages; +#endif + #if defined(__linux__) #include #include diff --git a/packages/runtime/src/scr_map.test.ts b/packages/runtime/src/scr_map.test.ts index b567884fc..035b3a0eb 100644 --- a/packages/runtime/src/scr_map.test.ts +++ b/packages/runtime/src/scr_map.test.ts @@ -14,7 +14,14 @@ test("number-key maps keep Map semantics across linear, direct and hashed indexe try { await exec("clang", [ "-std=c11", - "-O1", + // The runtime link set includes scr_json.c, scr_lib.c and scr_hash.c. + // With ASan/UBSan their optimized builds dominate the test's cost: -O1 + // takes about 90s (scr_hash.c alone 29s, its unrolled digest rounds + // instrumented) and regularly starved past this test's timeout when the + // lane shared the box. The test checks Map semantics under sanitizers, + // not codegen, so -O0 keeps every assertion and sanitizer check and + // builds in about 15s. + "-O0", "-Wall", "-Wextra", "-fsanitize=address,undefined", diff --git a/packages/runtime/src/scr_node_island_host.cjs b/packages/runtime/src/scr_node_island_host.cjs index c57d4b422..bf3ec2b74 100644 --- a/packages/runtime/src/scr_node_island_host.cjs +++ b/packages/runtime/src/scr_node_island_host.cjs @@ -289,31 +289,72 @@ process.on("newListener", (event, listener) => { const kind = eventKind(event); if (kind === 0) return; const id = nextListener++; - islandListeners.push({ event, listener, id }); + islandListeners.push({ event, listener, id, raw: undefined }); nativeHost.listen(kind, event, id, 1, 0); }); +function forget(i, kind) { + const entry = islandListeners[i]; + islandListeners.splice(i, 1); + nativeHost.listen(kind, entry.event, entry.id, 0, 0); +} + process.on("removeListener", (event, listener) => { const kind = eventKind(event); if (kind === 0) return; + // Node reports the removed registration as the raw listener it installed (a + // once wrapper removing itself reports the wrapper) or as the listener + // itself. The raw listener each entry recorded separates a once + // registration from an `on` registration of the same function: the removed + // one is the entry whose raw listener is no longer on `process`. + const live = process.rawListeners(event); + let same = -1; for (let i = islandListeners.length - 1; i >= 0; i--) { const entry = islandListeners[i]; - if (entry.event === event && entry.listener === listener) { - islandListeners.splice(i, 1); - nativeHost.listen(kind, event, entry.id, 0, 0); + if (entry.event !== event) continue; + if (entry.listener !== listener && entry.raw !== listener) continue; + if (entry.raw !== undefined && !live.includes(entry.raw)) { + forget(i, kind); return; } + if (same < 0) same = i; } + // Duplicate registrations of one function share a raw listener; Node + // removes the most recent, which is the last matching entry. + if (same >= 0) forget(same, kind); }); +// The raw listener is the function handed to the emitter (a once wrapper +// for process.once, which registers through process.on): tag each entry +// with it as its registration returns. Node emits newListener before it +// installs the listener, so the entry learns its raw only afterwards. +for (const name of ["on", "addListener", "prependListener"]) { + const add = process[name]; + process[name] = function addListener(event, listener) { + const start = islandListeners.length; + const result = Reflect.apply(add, this, arguments); + if (islandListeners.length > start) { + const registered = listener.listener ?? listener; + for (let i = start; i < islandListeners.length; i++) { + const entry = islandListeners[i]; + if (entry.event === event && entry.listener === registered && entry.raw === undefined) { + entry.raw = listener; + break; + } + } + } + return result; + }; +} + function emit(kind, id, name, arg) { const entry = islandListeners.find((e) => e.id === id); if (entry === undefined) return; // The registered function: the listener itself, or its once wrapper, // which removes itself before calling it. - const registered = process - .rawListeners(entry.event) - .find((raw) => (raw.listener ?? raw) === entry.listener); + const registered = + entry.raw ?? + process.rawListeners(entry.event).find((raw) => (raw.listener ?? raw) === entry.listener); if (registered === undefined) return; if (kind === EVENT_SIGNAL) Reflect.apply(registered, process, [name, arg]); else Reflect.apply(registered, process, [arg]); diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index 918086682..04d96aeb1 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -295,31 +295,72 @@ static const char scr_node_island_host_js[] = " const kind = eventKind(event);\012" " if (kind === 0) return;\012" " const id = nextListener++;\012" - " islandListeners.push({ event, listener, id });\012" + " islandListeners.push({ event, listener, id, raw: undefined });\012" " nativeHost.listen(kind, event, id, 1, 0);\012" "});\012" "\012" + "function forget(i, kind) {\012" + " const entry = islandListeners[i];\012" + " islandListeners.splice(i, 1);\012" + " nativeHost.listen(kind, entry.event, entry.id, 0, 0);\012" + "}\012" + "\012" "process.on(\"removeListener\", (event, listener) => {\012" " const kind = eventKind(event);\012" " if (kind === 0) return;\012" + " // Node reports the removed registration as the raw listener it installed (a\012" + " // once wrapper removing itself reports the wrapper) or as the listener\012" + " // itself. The raw listener each entry recorded separates a once\012" + " // registration from an `on` registration of the same function: the removed\012" + " // one is the entry whose raw listener is no longer on `process`.\012" + " const live = process.rawListeners(event);\012" + " let same = -1;\012" " for (let i = islandListeners.length - 1; i >= 0; i--) {\012" " const entry = islandListeners[i];\012" - " if (entry.event === event && entry.listener === listener) {\012" - " islandListeners.splice(i, 1);\012" - " nativeHost.listen(kind, event, entry.id, 0, 0);\012" + " if (entry.event !== event) continue;\012" + " if (entry.listener !== listener && entry.raw !== listener) continue;\012" + " if (entry.raw !== undefined && !live.includes(entry.raw)) {\012" + " forget(i, kind);\012" " return;\012" " }\012" + " if (same < 0) same = i;\012" " }\012" + " // Duplicate registrations of one function share a raw listener; Node\012" + " // removes the most recent, which is the last matching entry.\012" + " if (same >= 0) forget(same, kind);\012" "});\012" "\012" + "// The raw listener is the function handed to the emitter (a once wrapper\012" + "// for process.once, which registers through process.on): tag each entry\012" + "// with it as its registration returns. Node emits newListener before it\012" + "// installs the listener, so the entry learns its raw only afterwards.\012" + "for (const name of [\"on\", \"addListener\", \"prependListener\"]) {\012" + " const add = process[name];\012" + " process[name] = function addListener(event, listener) {\012" + " const start = islandListeners.length;\012" + " const result = Reflect.apply(add, this, arguments);\012" + " if (islandListeners.length > start) {\012" + " const registered = listener.listener \077\077 listener;\012" + " for (let i = start; i < islandListeners.length; i++) {\012" + " const entry = islandListeners[i];\012" + " if (entry.event === event && entry.listener === registered && entry.raw === undefined) {\012" + " entry.raw = listener;\012" + " break;\012" + " }\012" + " }\012" + " }\012" + " return result;\012" + " };\012" + "}\012" + "\012" "function emit(kind, id, name, arg) {\012" " const entry = islandListeners.find((e) => e.id === id);\012" " if (entry === undefined) return;\012" " // The registered function: the listener itself, or its once wrapper,\012" " // which removes itself before calling it.\012" - " const registered = process\012" - " .rawListeners(entry.event)\012" - " .find((raw) => (raw.listener \077\077 raw) === entry.listener);\012" + " const registered =\012" + " entry.raw \077\077\012" + " process.rawListeners(entry.event).find((raw) => (raw.listener \077\077 raw) === entry.listener);\012" " if (registered === undefined) return;\012" " if (kind === EVENT_SIGNAL) Reflect.apply(registered, process, [name, arg]);\012" " else Reflect.apply(registered, process, [arg]);\012" diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 4296ce791..47c56899c 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -2161,6 +2161,7 @@ static ScrStr *scr_str_case_conv(const ScrStr *s, int to_lower) { out->data[i] = (char)(c >= first && c <= last ? c ^ 0x20 : c); } out->data[s->len] = '\0'; + scr_str_note_ascii(out); return out; } ScrJsonBuf b; diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index cfbb0f980..a9138cedc 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -2118,6 +2118,14 @@ double scr_str_utf16_len(ScrStr *s) { return (double)(int64_t)units; } +/* scr_str_utf16_len as a size: proven-ASCII and short strings are counted + * here, without the call. */ +static inline size_t scr_str_len16(ScrStr *s) { + if (s->ascii) return s->len; + if (s->len <= 64) return scr_utf16_units_short(s->data, s->len); + return (size_t)scr_str_utf16_len(s); +} + /* Records the UTF-16 length of a string whose producer counted it (latin1 * text has one unit per byte), so scr_str_utf16_len does not walk it. Only * strings that scr_str_utf16_len would otherwise record are kept. */ @@ -3137,15 +3145,17 @@ ScrStr *scr_str_replace_literal(ScrStr *s, ScrStr *search, ScrStr *rep, bool all static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, bool at_start) { double target = scr_to_integer_or_infinity(maxLength); - size_t len16 = scr_sidx_len(s, scr_sidx_for(s)); + size_t len16 = scr_str_len16(s); if (!(target > (double)len16) || fill->len == 0) return scr_str_retain(s); /* Reject pad sizes malloc could not satisfy before the double→size_t * conversion can overflow (each unit is at most 3 bytes here: BMP chars * and lone surrogates; astral chars are 4 bytes for 2 units). */ if (target > (double)((SIZE_MAX - sizeof(ScrStr) - 1) / 4)) scr_oom(); size_t pad16 = (size_t)target - len16; - size_t fill16 = scr_sidx_len(fill, scr_sidx_for(fill)); - size_t reps = pad16 / fill16, rem16 = pad16 % fill16; + size_t fill16 = scr_str_len16(fill); + /* A one-unit fill, the common case, needs no division. */ + size_t reps = fill16 == 1 ? pad16 : pad16 / fill16; + size_t rem16 = fill16 == 1 ? 0 : pad16 % fill16; /* The truncated prefix of fill: whole chars while they fit in rem16 * units; a final astral char that doesn't fit contributes its high * surrogate. */ @@ -3167,9 +3177,14 @@ static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, size_t total = s->len + pad_b; ScrStr *r = scr_str_alloc(total, total); char *w = r->data + (at_start ? 0 : s->len); - for (size_t i = 0; i < reps; i++) { - memcpy(w, fill->data, fill->len); - w += fill->len; + if (fill->len == 1) { + memset(w, fill->data[0], reps); + w += reps; + } else { + for (size_t i = 0; i < reps; i++) { + memcpy(w, fill->data, fill->len); + w += fill->len; + } } memcpy(w, fill->data, prefix_b); w += prefix_b; @@ -3179,6 +3194,7 @@ static ScrStr *scr_pad_impl(ScrStr *s, double maxLength, ScrStr *fill, } memcpy(at_start ? w : r->data, s->data, s->len); r->data[total] = '\0'; + if (s->ascii && fill->ascii) scr_str_note_ascii(r); /* Only a piece that starts with a lone low surrogate can complete a * pair across a seam: the fill (after a fill or the receiver) or the * receiver (after a fill ending in a high surrogate). */ diff --git a/packages/runtime/src/scr_webstreams.c b/packages/runtime/src/scr_webstreams.c index e4b192b87..1ea9c754e 100644 --- a/packages/runtime/src/scr_webstreams.c +++ b/packages/runtime/src/scr_webstreams.c @@ -1,6 +1,7 @@ -/* WHATWG streams for checked-dynamic code: ReadableStream with its default - * reader and controller, and the queuing strategies, as untyped JavaScript - * reads them (globals and node:stream/web). The algorithms follow Node 24's +/* WHATWG streams for checked-dynamic code: ReadableStream, WritableStream + * and TransformStream with their default readers, writers and controllers, + * and the queuing strategies, as untyped JavaScript reads them (globals and + * node:stream/web). The algorithms follow Node 24's * lib/internal/webstreams step by step, including where Node departs from * the specification's promise usage (its read fast path, the lazily * materialized closed promises), so promise jobs run in Node's order. @@ -47,15 +48,24 @@ static void *ws_alloc(size_t size) { typedef struct WsObjType { void (*trace)(void *obj, ScrTraceVisit visit, void *ctx); void (*drop)(void *obj, bool gc); + /* For the states a class's handles show: the class name and its + * getters. */ + const char *cls; + const ScrDynHandleAccessor *accessors; } WsObjType; +/* `own` holds the properties a program set on the state's object: handles + * compare by their state, so every handle shows them. */ typedef struct WsObj { size_t rc; const WsObjType *type; + ScrDyn *own; /* +1, or NULL */ } WsObj; static void ws_obj_trace(void *obj, ScrTraceVisit visit, void *ctx) { - ((WsObj *)obj)->type->trace(obj, visit, ctx); + WsObj *o = obj; + if (o->own) visit(o->own, ctx); + o->type->trace(obj, visit, ctx); } static void ws_obj_gcfree(void *obj) { @@ -65,7 +75,9 @@ static void ws_obj_gcfree(void *obj) { } static void ws_obj_destroy(void *obj) { - ((WsObj *)obj)->type->drop(obj, false); + WsObj *o = obj; + if (o->own) scr_dyn_release(o->own); + o->type->drop(obj, false); scr_obj_free_note(); scr_cyc_free(obj); } @@ -96,6 +108,22 @@ static void ws_release(void *obj) { } } +/* The classes' getters (scr_dyn_handle_class.c), which own properties do + * not shadow. */ +static const ScrDynHandleAccessor ws_readable_accessors[] = {{"locked", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_reader_accessors[] = {{"closed", true}, {NULL, false}}; +static const ScrDynHandleAccessor ws_rcontroller_accessors[] = {{"desiredSize", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_writable_accessors[] = {{"locked", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_writer_accessors[] = { + {"closed", true}, {"desiredSize", false}, {"ready", true}, {NULL, false}}; +static const ScrDynHandleAccessor ws_wcontroller_accessors[] = {{"signal", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_transform_accessors[] = {{"readable", false}, {"writable", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_tcontroller_accessors[] = {{"desiredSize", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_text_encoder_accessors[] = { + {"encoding", false}, {"readable", false}, {"writable", false}, {NULL, false}}; +static const ScrDynHandleAccessor ws_text_decoder_accessors[] = { + {"encoding", false}, {"fatal", false}, {"ignoreBOM", false}, {"readable", false}, {"writable", false}, {NULL, false}}; + static void ws_visit(const void *child, ScrTraceVisit visit, void *ctx) { if (child) visit((void *)child, ctx); } @@ -610,13 +638,15 @@ struct WsReader { static SCR_TL ScrDyn *ws_released_error; /* "Reader released" */ static SCR_TL ScrDyn *ws_releasing_error; /* "Releasing reader" */ +static SCR_TL ScrDyn *ws_writer_released_error_value; /* "Writer has been released" */ static SCR_TL bool ws_teardown_registered; static void ws_teardown(void) { if (ws_released_error) scr_dyn_release(ws_released_error); if (ws_releasing_error) scr_dyn_release(ws_releasing_error); + if (ws_writer_released_error_value) scr_dyn_release(ws_writer_released_error_value); if (ws_resolved_promise) scr_promise_release(ws_resolved_promise); - ws_released_error = ws_releasing_error = NULL; + ws_released_error = ws_releasing_error = ws_writer_released_error_value = NULL; ws_resolved_promise = NULL; } @@ -664,7 +694,7 @@ static void ws_readable_drop(void *obj, bool gc) { if (s->closed_materialized) ws_drop_promise(s->closed.promise, gc); } -static const WsObjType ws_readable_type = {ws_readable_trace, ws_readable_drop}; +static const WsObjType ws_readable_type = {ws_readable_trace, ws_readable_drop, "ReadableStream", ws_readable_accessors}; static void ws_rcontroller_trace(void *obj, ScrTraceVisit visit, void *ctx) { WsRController *c = obj; @@ -688,7 +718,7 @@ static void ws_rcontroller_drop(void *obj, bool gc) { ws_drop_obj(c->stream, gc); } -static const WsObjType ws_rcontroller_type = {ws_rcontroller_trace, ws_rcontroller_drop}; +static const WsObjType ws_rcontroller_type = {ws_rcontroller_trace, ws_rcontroller_drop, "ReadableStreamDefaultController", ws_rcontroller_accessors}; static void ws_reader_trace(void *obj, ScrTraceVisit visit, void *ctx) { WsReader *r = obj; @@ -715,7 +745,7 @@ static void ws_reader_drop(void *obj, bool gc) { ws_drop_obj(r->stream, gc); } -static const WsObjType ws_reader_type = {ws_reader_trace, ws_reader_drop}; +static const WsObjType ws_reader_type = {ws_reader_trace, ws_reader_drop, "ReadableStreamDefaultReader", ws_reader_accessors}; /* ── handles ───────────────────────────────────────────────────────── */ @@ -2019,6 +2049,2057 @@ static bool ws_readable_unusable(const ScrDyn *stream) { static const ScrWebStreamBridge ws_fetch_bridge = {ws_reader_read_into, ws_readable_unusable}; +/* ── WritableStream (lib/internal/webstreams/writablestream.js) ──── */ + +typedef enum { WS_W_WRITABLE, WS_W_ERRORING, WS_W_ERRORED, WS_W_CLOSED } WsWState; + +typedef struct WsWritable WsWritable; +typedef struct WsWriter WsWriter; +typedef struct WsWController WsWController; + +/* Native sink algorithms for streams the runtime makes (a TransformStream's + * writable side): write and close answer the algorithm's result (+1 dyn, + * or NULL with the exception pending), abort a promise (+1). The context + * is a WsObj the controller holds until it clears its algorithms. */ +typedef struct WsSinkOps { + ScrDyn *(*write)(void *ctx, WsWController *c, ScrDyn *chunk); + ScrPromise *(*close)(void *ctx, WsWController *c); + ScrPromise *(*abort)(void *ctx, WsWController *c, ScrDyn *reason); +} WsSinkOps; + +/* A promise record (PromiseWithResolvers, resolvedRecord or + * rejectedHandledRecord): the promise (+1), and whether its resolvers are + * live. NULL until materialized. A pipe parks on a writer's backpressure + * by installing a record whose resolve is its hook (a forever-pending + * promise with a no-op reject, as Node's pipeTo does). */ +typedef struct WsRecord { + ScrPromise *promise; + bool live; + void (*hook)(void *ctx); + void *hook_ctx; /* weak: the pipe owns the writer */ +} WsRecord; + +typedef struct WsPipe WsPipe; + +/* A write request: its promise (+1), or the shared tracker of the pipe + * that wrote the chunk (+1; pipeTo). */ +typedef struct WsWriteRequest { + ScrPromise *promise; + WsPipe *tracker; +} WsWriteRequest; + +struct WsWritable { + WsObj obj; + WsWState state; + ScrDyn *stored_error; /* +1, or NULL for undefined */ + WsWriter *writer; /* +1 while locked */ + WsWController *controller; + ScrPromise *close_request; /* +1 while queued */ + WsWriteRequest inflight_write; /* both NULL when none is in flight */ + ScrPromise *inflight_close; /* +1 */ + bool close_queued_or_inflight; + /* pendingAbortRequest */ + ScrPromise *abort_request; /* +1 */ + ScrDyn *abort_reason; /* +1, or NULL for undefined */ + bool abort_was_already_erroring; + bool backpressure; + /* writeRequests, a ring. */ + WsWriteRequest *requests; + size_t requests_head, requests_len, requests_cap; +}; + +struct WsWriter { + WsObj obj; + WsWritable *stream; /* +1 while attached */ + WsRecord closed; + WsRecord ready; +}; + +struct WsWController { + WsObj obj; + WsWritable *stream; /* +1 */ + WsQueue queue; + double queue_total_size; + double high_water_mark; + bool started, algorithms_cleared; + ScrDyn *sink; + ScrDyn *write_fn, *close_fn, *abort_fn; + ScrDyn *size_fn; /* NULL = the default size algorithm */ + const WsSinkOps *native; + void *native_ctx; /* +1 */ + /* The AbortController, made when the signal is first read; until then + * an abort records its reason (no listener can observe it). */ + ScrDyn *abort_controller; /* +1 */ + bool aborted; + ScrDyn *aborted_reason; /* +1 */ +}; + +/* kCloseSentinel. */ +static ScrDyn ws_close_sentinel = {.rc = SIZE_MAX, .kind = SCR_DYN_UNDEF}; + +static ScrDyn *ws_writer_released_error(void) { + if (!ws_writer_released_error_value) { + ws_register_teardown(); + ws_writer_released_error_value = ws_state_error_value("Writer has been released"); + } + return ws_writer_released_error_value; +} + +static void ws_writable_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsWritable *s = obj; + ws_visit(s->stored_error, visit, ctx); + ws_visit(s->writer, visit, ctx); + ws_visit(s->controller, visit, ctx); + ws_visit(s->close_request, visit, ctx); + ws_visit(s->inflight_write.promise, visit, ctx); + ws_visit(s->inflight_write.tracker, visit, ctx); + ws_visit(s->inflight_close, visit, ctx); + ws_visit(s->abort_request, visit, ctx); + ws_visit(s->abort_reason, visit, ctx); + for (size_t i = 0; i < s->requests_len; i++) { + WsWriteRequest *q = &s->requests[(s->requests_head + i) % s->requests_cap]; + ws_visit(q->promise, visit, ctx); + ws_visit(q->tracker, visit, ctx); + } +} + +static void ws_writable_drop(void *obj, bool gc) { + WsWritable *s = obj; + ws_drop_dyn(s->stored_error, gc); + ws_drop_obj(s->writer, gc); + ws_drop_obj(s->controller, gc); + ws_drop_promise(s->close_request, gc); + ws_drop_promise(s->inflight_write.promise, gc); + ws_drop_obj(s->inflight_write.tracker, gc); + ws_drop_promise(s->inflight_close, gc); + ws_drop_promise(s->abort_request, gc); + ws_drop_dyn(s->abort_reason, gc); + for (size_t i = 0; i < s->requests_len; i++) { + WsWriteRequest *q = &s->requests[(s->requests_head + i) % s->requests_cap]; + ws_drop_promise(q->promise, gc); + ws_drop_obj(q->tracker, gc); + } + free(s->requests); +} + +static const WsObjType ws_writable_type = {ws_writable_trace, ws_writable_drop, "WritableStream", ws_writable_accessors}; + +static void ws_writer_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsWriter *w = obj; + ws_visit(w->stream, visit, ctx); + ws_visit(w->closed.promise, visit, ctx); + ws_visit(w->ready.promise, visit, ctx); +} + +static void ws_writer_drop(void *obj, bool gc) { + WsWriter *w = obj; + ws_drop_obj(w->stream, gc); + ws_drop_promise(w->closed.promise, gc); + ws_drop_promise(w->ready.promise, gc); +} + +static const WsObjType ws_writer_type = {ws_writer_trace, ws_writer_drop, "WritableStreamDefaultWriter", ws_writer_accessors}; + +static void ws_wcontroller_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsWController *c = obj; + ws_visit(c->stream, visit, ctx); + ws_queue_trace(&c->queue, visit, ctx); + ws_visit(c->sink, visit, ctx); + ws_visit(c->write_fn, visit, ctx); + ws_visit(c->close_fn, visit, ctx); + ws_visit(c->abort_fn, visit, ctx); + ws_visit(c->size_fn, visit, ctx); + ws_visit(c->native_ctx, visit, ctx); + ws_visit(c->abort_controller, visit, ctx); + ws_visit(c->aborted_reason, visit, ctx); +} + +static void ws_wcontroller_drop(void *obj, bool gc) { + WsWController *c = obj; + ws_queue_drop(&c->queue, gc); + ws_drop_dyn(c->sink, gc); + ws_drop_dyn(c->write_fn, gc); + ws_drop_dyn(c->close_fn, gc); + ws_drop_dyn(c->abort_fn, gc); + ws_drop_dyn(c->size_fn, gc); + ws_drop_obj(c->native_ctx, gc); + ws_drop_dyn(c->abort_controller, gc); + ws_drop_dyn(c->aborted_reason, gc); + ws_drop_obj(c->stream, gc); +} + +static const WsObjType ws_wcontroller_type = {ws_wcontroller_trace, ws_wcontroller_drop, "WritableStreamDefaultController", ws_wcontroller_accessors}; + +static ScrDyn *ws_writable_box(WsWritable *s) { return ws_box(s, SCR_DYNH_WS_WRITABLE); } +static ScrDyn *ws_writer_box(WsWriter *w) { return ws_box(w, SCR_DYNH_WS_WRITER); } +static ScrDyn *ws_wcontroller_box(WsWController *c) { return ws_box(c, SCR_DYNH_WS_WCONTROLLER); } + +static WsWritable *ws_writable_of(const ScrDyn *d) { + return d && d->kind == SCR_DYN_HANDLE && d->v.handle.tag == SCR_DYNH_WS_WRITABLE ? d->v.handle.ptr : NULL; +} + +/* undefined for a NULL stored value (+1). */ +static ScrDyn *ws_or_undef(ScrDyn *d) { return d ? scr_dyn_retain(d) : ws_undef(); } + +/* ── records ── */ + +static void ws_record_set(WsRecord *r, ScrPromise *p, bool live) { + if (r->promise) scr_promise_release(r->promise); + r->promise = p; + r->live = live; + r->hook = NULL; + r->hook_ctx = NULL; +} + +static void ws_record_clear(WsRecord *r) { ws_record_set(r, NULL, false); } + +static bool ws_record_pending(const WsRecord *r) { + return r->promise && scr_promise_is_pending(r->promise); +} + +/* record.resolve?.() */ +static void ws_record_resolve(WsRecord *r) { + if (r->hook) { + r->hook(r->hook_ctx); + return; + } + if (!r->promise || !r->live || !scr_promise_is_pending(r->promise)) return; + ScrDyn *u = ws_undef(); + ws_fulfill(r->promise, u); + scr_dyn_release(u); +} + +static void ws_record_reject(WsRecord *r, ScrDyn *reason) { + if (r->hook || !r->promise || !r->live || !scr_promise_is_pending(r->promise)) return; + ScrDyn *e = ws_or_undef(reason); + ws_reject(r->promise, e); + scr_dyn_release(e); +} + +static void ws_record_resolved(WsRecord *r) { + ScrDyn *u = ws_undef(); + ws_record_set(r, ws_promise_resolved(u), false); + scr_dyn_release(u); +} + +static void ws_record_rejected_handled(WsRecord *r, ScrDyn *reason) { + ScrDyn *e = ws_or_undef(reason); + ScrPromise *p = ws_promise_rejected(e); + scr_dyn_release(e); + ws_set_handled(p); + ws_record_set(r, p, false); +} + +static void ws_promise_fulfill_undefined(ScrPromise *p) { + ScrDyn *u = ws_undef(); + ws_fulfill(p, u); + scr_dyn_release(u); +} + +static void ws_promise_reject_with(ScrPromise *p, ScrDyn *reason) { + ScrDyn *e = ws_or_undef(reason); + ws_reject(p, e); + scr_dyn_release(e); +} + +/* PromiseReject(e) / PromiseResolve() as boxed values. */ +static ScrPromise *ws_promise_rejected_with(ScrDyn *reason) { + ScrDyn *e = ws_or_undef(reason); + ScrPromise *p = ws_promise_rejected(e); + scr_dyn_release(e); + return p; +} + +static ScrPromise *ws_promise_resolved_undefined(void) { + ScrDyn *u = ws_undef(); + ScrPromise *p = ws_promise_resolved(u); + scr_dyn_release(u); + return p; +} + +static ScrPromise *ws_promise_state_rejected(const char *message) { + ScrDyn *error = ws_state_error_value(message); + ScrPromise *p = ws_promise_rejected(error); + scr_dyn_release(error); + return p; +} + +/* ── the write request queue ── */ + +static void ws_requests_push(WsWritable *s, WsWriteRequest q) { + if (s->requests_len == s->requests_cap) { + size_t cap = s->requests_cap ? s->requests_cap * 2 : 4; + WsWriteRequest *requests = malloc(cap * sizeof *requests); + if (!requests) ws_oom(); + for (size_t i = 0; i < s->requests_len; i++) + requests[i] = s->requests[(s->requests_head + i) % s->requests_cap]; + free(s->requests); + s->requests = requests; + s->requests_head = 0; + s->requests_cap = cap; + } + s->requests[(s->requests_head + s->requests_len) % s->requests_cap] = q; + s->requests_len++; +} + +static WsWriteRequest ws_requests_shift(WsWritable *s) { + WsWriteRequest p = s->requests[s->requests_head]; + s->requests_head = (s->requests_head + 1) % s->requests_cap; + s->requests_len--; + if (!s->requests_len) s->requests_head = 0; + return p; +} + +static void ws_pipe_tracker_resolve(WsPipe *p); +static void ws_pipe_tracker_reject(WsPipe *p, ScrDyn *error); + +static bool ws_write_request_live(WsWriteRequest q) { return q.promise || q.tracker; } + +/* request.resolve() and request.reject(error); the request's reference + * goes. */ +static void ws_write_request_resolve(WsWriteRequest q) { + if (q.promise) { + ws_promise_fulfill_undefined(q.promise); + scr_promise_release(q.promise); + } else if (q.tracker) { + ws_pipe_tracker_resolve(q.tracker); + ws_release(q.tracker); + } +} + +static void ws_write_request_reject(WsWriteRequest q, ScrDyn *error) { + ScrDyn *e = ws_or_undef(error); + if (q.promise) { + ws_reject(q.promise, e); + scr_promise_release(q.promise); + } else if (q.tracker) { + ws_pipe_tracker_reject(q.tracker, e); + ws_release(q.tracker); + } + scr_dyn_release(e); +} + +/* ── the stream's abstract operations ── */ + +static bool ws_writable_locked(WsWritable *s) { return s->writer != NULL; } + +static void ws_wcontroller_advance_queue_if_needed(WsWController *c); +static void ws_wcontroller_clear_algorithms(WsWController *c); +static void ws_writable_finish_erroring(WsWritable *s); + +/* writerClosedPromise / writerReadyPromise: the records, materialized. */ +static ScrPromise *ws_writer_closed_promise(WsWriter *w) { + if (!w->closed.promise) { + WsWritable *s = w->stream; + if (!s) ws_record_rejected_handled(&w->closed, ws_writer_released_error()); + else if (s->state == WS_W_WRITABLE || s->state == WS_W_ERRORING) ws_record_set(&w->closed, scr_promise_new(), true); + else if (s->state == WS_W_CLOSED) ws_record_resolved(&w->closed); + else ws_record_rejected_handled(&w->closed, s->stored_error); + } + return w->closed.promise; +} + +static ScrPromise *ws_writer_ready_promise(WsWriter *w) { + if (!w->ready.promise) { + WsWritable *s = w->stream; + if (!s) ws_record_rejected_handled(&w->ready, ws_writer_released_error()); + else if (s->state == WS_W_WRITABLE) { + if (!s->close_queued_or_inflight && s->backpressure) ws_record_set(&w->ready, scr_promise_new(), true); + else ws_record_resolved(&w->ready); + } else if (s->state == WS_W_CLOSED) ws_record_resolved(&w->ready); + else ws_record_rejected_handled(&w->ready, s->stored_error); + } + return w->ready.promise; +} + +static void ws_writer_ensure_ready_rejected(WsWriter *w, ScrDyn *error) { + if (ws_record_pending(&w->ready)) { + ws_record_reject(&w->ready, error); + ws_set_handled(w->ready.promise); + } else { + ws_record_clear(&w->ready); + } +} + +static void ws_writer_ensure_closed_rejected(WsWriter *w, ScrDyn *error) { + if (ws_record_pending(&w->closed)) { + ws_record_reject(&w->closed, error); + ws_set_handled(w->closed.promise); + } else { + ws_record_clear(&w->closed); + } +} + +/* abortController.abort(reason) */ +static void ws_wcontroller_signal_abort(WsWController *c, ScrDyn *reason) { + if (c->aborted) return; + c->aborted = true; + c->aborted_reason = ws_or_undef(reason); + if (!c->abort_controller) return; + ScrDyn *arg = c->aborted_reason; + ScrDyn *r = scr_dyn_invoke(c->abort_controller, "abort", &arg, 1, "abortController.abort"); + if (r) scr_dyn_release(r); +} + +static void ws_writable_start_erroring(WsWritable *s, ScrDyn *reason) { + WsWController *c = s->controller; + s->state = WS_W_ERRORING; + s->stored_error = ws_or_undef(reason); + if (s->writer) ws_writer_ensure_ready_rejected(s->writer, s->stored_error); + if (!ws_write_request_live(s->inflight_write) && !s->inflight_close && c->started) ws_writable_finish_erroring(s); +} + +static void ws_writable_reject_close_and_closed_if_needed(WsWritable *s) { + if (s->close_request) { + ScrPromise *p = s->close_request; + s->close_request = NULL; + s->close_queued_or_inflight = false; + ws_promise_reject_with(p, s->stored_error); + scr_promise_release(p); + } + if (s->writer && ws_record_pending(&s->writer->closed)) { + ws_set_handled(s->writer->closed.promise); + ws_record_reject(&s->writer->closed, s->stored_error); + } +} + +/* The abort request's reaction to the sink's abort algorithm. */ +typedef struct WsAbortWait { + WsObj obj; + WsWritable *stream; + ScrPromise *abort_request; +} WsAbortWait; + +static void ws_abort_wait_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsAbortWait *a = obj; + ws_visit(a->stream, visit, ctx); + ws_visit(a->abort_request, visit, ctx); +} + +static void ws_abort_wait_drop(void *obj, bool gc) { + WsAbortWait *a = obj; + ws_drop_obj(a->stream, gc); + ws_drop_promise(a->abort_request, gc); +} + +static const WsObjType ws_abort_wait_type = {ws_abort_wait_trace, ws_abort_wait_drop}; + +static void ws_abort_sink_fulfilled(void *ctx, ScrDyn *value) { + (void)value; + WsAbortWait *a = ctx; + ws_promise_fulfill_undefined(a->abort_request); + ws_writable_reject_close_and_closed_if_needed(a->stream); +} + +static void ws_abort_sink_rejected(void *ctx, ScrDyn *error) { + WsAbortWait *a = ctx; + ws_reject(a->abort_request, error); + ws_writable_reject_close_and_closed_if_needed(a->stream); +} + +/* controller[kAbort](reason): the abort algorithm's promise (+1). */ +static ScrPromise *ws_wcontroller_abort_steps(WsWController *c, ScrDyn *reason) { + ScrPromise *result; + if (c->algorithms_cleared) { + result = ws_promise_resolved_undefined(); + } else if (c->native) { + result = c->native->abort(c->native_ctx, c, reason); + } else if (c->abort_fn) { + /* createPromiseCallback1Param: an async arrow around the call. */ + ScrDyn *abort = scr_dyn_retain(c->abort_fn); + ScrDyn *sink = scr_dyn_retain(c->sink); + ScrDyn *arg = ws_or_undef(reason); + ScrDyn *r = ws_call(abort, sink, &arg, 1, "sink.abort"); + scr_dyn_release(arg); + scr_dyn_release(abort); + scr_dyn_release(sink); + if (!r) { + result = ws_promise_rejected_pending(); + } else { + result = ws_promise_resolving(r); + scr_dyn_release(r); + } + } else { + result = ws_promise_resolved_undefined(); /* nonOpCancel */ + } + ws_wcontroller_clear_algorithms(c); + return result; +} + +static void ws_writable_finish_erroring(WsWritable *s) { + s->state = WS_W_ERRORED; + WsWController *c = s->controller; + /* controller[kError](): resetQueue */ + ws_queue_reset(&c->queue); + c->queue_total_size = 0; + ScrDyn *stored = ws_or_undef(s->stored_error); + while (s->requests_len) ws_write_request_reject(ws_requests_shift(s), stored); + if (!s->abort_request) { + ws_writable_reject_close_and_closed_if_needed(s); + scr_dyn_release(stored); + return; + } + ScrPromise *abort_request = s->abort_request; + ScrDyn *abort_reason = s->abort_reason; + bool was_already_erroring = s->abort_was_already_erroring; + s->abort_request = NULL; + s->abort_reason = NULL; + s->abort_was_already_erroring = false; + if (was_already_erroring) { + ws_reject(abort_request, stored); + ws_writable_reject_close_and_closed_if_needed(s); + scr_promise_release(abort_request); + if (abort_reason) scr_dyn_release(abort_reason); + scr_dyn_release(stored); + return; + } + /* The closed promises reject once the sink's abort settles: materialize + * them now, so an observation in between sees them pending. */ + if (s->writer && !s->writer->closed.promise) ws_record_set(&s->writer->closed, scr_promise_new(), true); + ScrPromise *sink = ws_wcontroller_abort_steps(c, abort_reason); + WsAbortWait *a = ws_obj_new(sizeof *a, &ws_abort_wait_type); + a->stream = ws_retain(s); + a->abort_request = abort_request; /* the reference moves */ + ws_then(sink, ws_abort_sink_fulfilled, ws_abort_sink_rejected, a, false); + scr_promise_release(sink); + if (abort_reason) scr_dyn_release(abort_reason); + scr_dyn_release(stored); +} + +static void ws_writable_deal_with_rejection(WsWritable *s, ScrDyn *error) { + if (s->state == WS_W_WRITABLE) { + ws_writable_start_erroring(s, error); + return; + } + ws_writable_finish_erroring(s); +} + +/* writableStreamAbort: +1 promise. */ +static ScrPromise *ws_writable_abort(WsWritable *s, ScrDyn *reason) { + if (s->state == WS_W_CLOSED || s->state == WS_W_ERRORED) return ws_promise_resolved_undefined(); + ws_wcontroller_signal_abort(s->controller, reason); + if (s->state == WS_W_CLOSED || s->state == WS_W_ERRORED) return ws_promise_resolved_undefined(); + if (s->abort_request) return scr_promise_retain(s->abort_request); + bool was_already_erroring = false; + if (s->state == WS_W_ERRORING) { + was_already_erroring = true; + reason = NULL; + } + s->abort_request = scr_promise_new(); + s->abort_reason = reason && reason->kind != SCR_DYN_UNDEF ? scr_dyn_retain(reason) : NULL; + s->abort_was_already_erroring = was_already_erroring; + ScrPromise *result = scr_promise_retain(s->abort_request); + if (!was_already_erroring) ws_writable_start_erroring(s, reason); + return result; +} + +static void ws_wcontroller_close(WsWController *c); + +/* writableStreamClose: +1 promise. */ +static ScrPromise *ws_writable_close(WsWritable *s) { + if (s->state == WS_W_CLOSED || s->state == WS_W_ERRORED) + return ws_promise_state_rejected("WritableStream is closed"); + s->close_request = scr_promise_new(); + s->close_queued_or_inflight = true; + ScrPromise *result = scr_promise_retain(s->close_request); + if (s->writer && s->backpressure && s->state == WS_W_WRITABLE) ws_record_resolve(&s->writer->ready); + ws_wcontroller_close(s->controller); + return result; +} + +static void ws_writable_update_backpressure(WsWController *c, WsWritable *s) { + bool backpressure = c->high_water_mark - c->queue_total_size <= 0; + bool changed = s->backpressure != backpressure; + s->backpressure = backpressure; + if (s->writer && changed) { + if (backpressure) ws_record_clear(&s->writer->ready); + else ws_record_resolve(&s->writer->ready); + } +} + +static void ws_writable_finish_inflight_write(WsWritable *s) { + WsWriteRequest q = s->inflight_write; + s->inflight_write = (WsWriteRequest){NULL, NULL}; + ws_write_request_resolve(q); +} + +static void ws_writable_finish_inflight_write_with_error(WsWritable *s, ScrDyn *error) { + WsWriteRequest q = s->inflight_write; + s->inflight_write = (WsWriteRequest){NULL, NULL}; + ws_write_request_reject(q, error); + ws_writable_deal_with_rejection(s, error); +} + +static void ws_writable_finish_inflight_close(WsWritable *s) { + ScrPromise *p = s->inflight_close; + s->inflight_close = NULL; + s->close_queued_or_inflight = false; + ws_promise_fulfill_undefined(p); + scr_promise_release(p); + if (s->state == WS_W_ERRORING) { + if (s->stored_error) scr_dyn_release(s->stored_error); + s->stored_error = NULL; + if (s->abort_request) { + ScrPromise *a = s->abort_request; + s->abort_request = NULL; + if (s->abort_reason) scr_dyn_release(s->abort_reason); + s->abort_reason = NULL; + ws_promise_fulfill_undefined(a); + scr_promise_release(a); + } + } + s->state = WS_W_CLOSED; + if (s->writer) ws_record_resolve(&s->writer->closed); +} + +static void ws_writable_finish_inflight_close_with_error(WsWritable *s, ScrDyn *error) { + ScrPromise *p = s->inflight_close; + s->inflight_close = NULL; + s->close_queued_or_inflight = false; + ws_reject(p, error); + scr_promise_release(p); + if (s->abort_request) { + ScrPromise *a = s->abort_request; + s->abort_request = NULL; + if (s->abort_reason) scr_dyn_release(s->abort_reason); + s->abort_reason = NULL; + ws_reject(a, error); + scr_promise_release(a); + } + ws_writable_deal_with_rejection(s, error); +} + +/* ── the writer ── */ + +static WsWriter *ws_writer_new(WsWritable *s) { + if (ws_writable_locked(s)) { + ws_state_error("WritableStream is locked"); + return NULL; + } + WsWriter *w = ws_obj_new(sizeof *w, &ws_writer_type); + w->stream = ws_retain(s); + s->writer = ws_retain(w); + return w; +} + +static void ws_writer_release_lock(WsWriter *w) { + WsWritable *s = w->stream; + ScrDyn *released = ws_writer_released_error(); + ws_writer_ensure_ready_rejected(w, released); + ws_writer_ensure_closed_rejected(w, released); + s->writer = NULL; + w->stream = NULL; + ws_release(w); /* the stream's reference */ + ws_release(s); +} + +static ScrDyn *ws_writer_desired_size(WsWriter *w) { + WsWritable *s = w->stream; + switch (s->state) { + case WS_W_ERRORED: + case WS_W_ERRORING: return scr_dyn_new_null(); + case WS_W_CLOSED: return scr_dyn_new_num(0); + default: return scr_dyn_new_num(s->controller->high_water_mark - s->controller->queue_total_size); + } +} + +/* writableStreamDefaultControllerGetChunkSize: the size algorithm's raw + * result (+1), or NULL for 1. */ +static ScrDyn *ws_wcontroller_chunk_size(WsWController *c, ScrDyn *chunk); +static void ws_wcontroller_write(WsWController *c, ScrDyn *chunk, ScrDyn *size); + +/* writableStreamDefaultWriterWrite: +1 promise. */ +static ScrPromise *ws_writer_write(WsWriter *w, ScrDyn *chunk) { + WsWritable *s = w->stream; + ws_retain(s); + WsWController *c = s->controller; + ScrDyn *size = ws_wcontroller_chunk_size(c, chunk); + ScrPromise *result; + if (s != w->stream) result = ws_promise_state_rejected("Mismatched WritableStreams"); + else if (s->state == WS_W_ERRORED) result = ws_promise_rejected_with(s->stored_error); + else if (s->close_queued_or_inflight || s->state == WS_W_CLOSED) + result = ws_promise_state_rejected("WritableStream is closed"); + else if (s->state == WS_W_ERRORING) result = ws_promise_rejected_with(s->stored_error); + else { + ScrPromise *request = scr_promise_new(); + ws_requests_push(s, (WsWriteRequest){request, NULL}); + result = scr_promise_retain(request); + ws_wcontroller_write(c, chunk, size); + } + if (size) scr_dyn_release(size); + ws_release(s); + return result; +} + +/* ── the default controller ── */ + +static void ws_wcontroller_clear_algorithms(WsWController *c) { + if (c->algorithms_cleared) return; + c->algorithms_cleared = true; + ScrDyn *sink = c->sink, *write = c->write_fn, *close = c->close_fn, *abort = c->abort_fn, *size = c->size_fn; + void *native_ctx = c->native_ctx; + c->sink = c->write_fn = c->close_fn = c->abort_fn = c->size_fn = NULL; + c->native_ctx = NULL; + if (sink) scr_dyn_release(sink); + if (write) scr_dyn_release(write); + if (close) scr_dyn_release(close); + if (abort) scr_dyn_release(abort); + if (size) scr_dyn_release(size); + ws_release(native_ctx); +} + +static void ws_wcontroller_error(WsWController *c, ScrDyn *error) { + ws_wcontroller_clear_algorithms(c); + ws_writable_start_erroring(c->stream, error); +} + +static void ws_wcontroller_error_if_needed(WsWController *c, ScrDyn *error) { + if (c->stream->state == WS_W_WRITABLE) ws_wcontroller_error(c, error); +} + +static ScrDyn *ws_wcontroller_chunk_size(WsWController *c, ScrDyn *chunk) { + if (c->algorithms_cleared || !c->size_fn) return NULL; + ScrDyn *size_fn = scr_dyn_retain(c->size_fn); + ScrDyn *size = ws_call(size_fn, scr_dyn_undefined(), &chunk, 1, "strategy.size"); + scr_dyn_release(size_fn); + if (!size) { + ScrDyn *error = ws_take_pending(); + ws_wcontroller_error_if_needed(c, error); + scr_dyn_release(error); + } + return size; +} + +/* writableStreamDefaultControllerWrite: enqueueValueWithSize coerces the + * size; a failure errors the stream. */ +static void ws_wcontroller_write(WsWController *c, ScrDyn *chunk, ScrDyn *size_value) { + double size = 1; + if (size_value) { + size = scr_dyn_number_coerce(size_value); + if (!scr_exc_pending() && (isnan(size) || size < 0 || isinf(size))) ws_range_arg_value("size", size_value); + if (scr_exc_pending()) { + ScrDyn *error = ws_take_pending(); + ws_wcontroller_error_if_needed(c, error); + scr_dyn_release(error); + return; + } + } + ws_queue_push(&c->queue, chunk, size); + c->queue_total_size += size; + WsWritable *s = c->stream; + if (!s->close_queued_or_inflight && s->state == WS_W_WRITABLE) ws_writable_update_backpressure(c, s); + ws_wcontroller_advance_queue_if_needed(c); +} + +static void ws_wcontroller_close(WsWController *c) { + ws_queue_push(&c->queue, &ws_close_sentinel, 0); + ws_wcontroller_advance_queue_if_needed(c); +} + +static void ws_write_fulfilled(void *ctx, ScrDyn *value) { + (void)value; + WsWController *c = ctx; + WsWritable *s = c->stream; + ws_writable_finish_inflight_write(s); + double size; + ScrDyn *chunk = ws_queue_shift(&c->queue, &size); + scr_dyn_release(chunk); + c->queue_total_size = fmax(0, c->queue_total_size - size); + if (!s->close_queued_or_inflight && s->state == WS_W_WRITABLE) ws_writable_update_backpressure(c, s); + ws_wcontroller_advance_queue_if_needed(c); +} + +static void ws_write_rejected(void *ctx, ScrDyn *error) { + WsWController *c = ctx; + if (c->stream->state == WS_W_WRITABLE) ws_wcontroller_clear_algorithms(c); + ws_writable_finish_inflight_write_with_error(c->stream, error); +} + +static void ws_wcontroller_process_write(WsWController *c, ScrDyn *chunk) { + WsWritable *s = c->stream; + s->inflight_write = ws_requests_shift(s); + ScrDyn *result; + if (c->native) { + result = c->native->write(c->native_ctx, c, chunk); + } else if (c->write_fn) { + ScrDyn *controller = ws_wcontroller_box(c); + ScrDyn *write = scr_dyn_retain(c->write_fn); + ScrDyn *sink = scr_dyn_retain(c->sink); + ScrDyn *args[2] = {chunk, controller}; + result = ws_call(write, sink, args, 2, "sink.write"); + scr_dyn_release(write); + scr_dyn_release(sink); + scr_dyn_release(controller); + } else { + result = ws_undef(); /* nonOpCallback */ + } + if (result == WS_PARKED) return; + if (!result) { + ScrPromise *rejected = ws_promise_rejected_pending(); + ws_then(rejected, ws_write_fulfilled, ws_write_rejected, ws_retain(c), false); + scr_promise_release(rejected); + return; + } + ws_then_algorithm_result(result, ws_write_fulfilled, ws_write_rejected, c); + scr_dyn_release(result); +} + +static void ws_close_fulfilled(void *ctx, ScrDyn *value) { + (void)value; + ws_writable_finish_inflight_close(((WsWController *)ctx)->stream); +} + +static void ws_close_rejected(void *ctx, ScrDyn *error) { + ws_writable_finish_inflight_close_with_error(((WsWController *)ctx)->stream, error); +} + +static void ws_wcontroller_process_close(WsWController *c) { + WsWritable *s = c->stream; + s->inflight_close = s->close_request; + s->close_request = NULL; + double size; + ScrDyn *sentinel = ws_queue_shift(&c->queue, &size); + (void)sentinel; + c->queue_total_size = fmax(0, c->queue_total_size - size); + ScrPromise *sink_close; + if (c->native) { + sink_close = c->native->close(c->native_ctx, c); + } else if (c->close_fn) { + /* createPromiseCallbackNoParams: an async arrow around the call. */ + ScrDyn *close = scr_dyn_retain(c->close_fn); + ScrDyn *sink = scr_dyn_retain(c->sink); + ScrDyn *r = ws_call(close, sink, NULL, 0, "sink.close"); + scr_dyn_release(close); + scr_dyn_release(sink); + if (!r) { + sink_close = ws_promise_rejected_pending(); + } else { + sink_close = ws_promise_resolving(r); + scr_dyn_release(r); + } + } else { + sink_close = ws_promise_resolved_undefined(); /* nonOpCancel */ + } + ws_wcontroller_clear_algorithms(c); + ws_then(sink_close, ws_close_fulfilled, ws_close_rejected, ws_retain(c), false); + scr_promise_release(sink_close); +} + +static void ws_wcontroller_advance_queue_if_needed(WsWController *c) { + WsWritable *s = c->stream; + if (!c->started || ws_write_request_live(s->inflight_write)) return; + if (s->state == WS_W_ERRORING) { + ws_writable_finish_erroring(s); + return; + } + if (!c->queue.length) return; + ScrDyn *value = c->queue.values[c->queue.head]; + if (value == &ws_close_sentinel) ws_wcontroller_process_close(c); + else ws_wcontroller_process_write(c, value); +} + +static void ws_wstart_fulfilled(void *ctx, ScrDyn *value) { + (void)value; + WsWController *c = ctx; + c->started = true; + ws_wcontroller_advance_queue_if_needed(c); +} + +static void ws_wstart_rejected(void *ctx, ScrDyn *error) { + WsWController *c = ctx; + c->started = true; + ws_writable_deal_with_rejection(c->stream, error); +} + +/* setupWritableStreamDefaultController after the algorithms are in place. */ +static void ws_wcontroller_started_with(WsWController *c, ScrDyn *start_result) { + if (!ws_is_object(start_result)) { + ws_queue_microtask(ws_wstart_fulfilled, ws_retain(c)); + return; + } + ScrPromise *p = ws_promise_resolving(start_result); + if (!p) p = ws_promise_rejected_pending(); + ws_then(p, ws_wstart_fulfilled, ws_wstart_rejected, ws_retain(c), false); + scr_promise_release(p); +} + +static WsWController *ws_writable_attach(WsWritable *s, double high_water_mark) { + WsWController *c = ws_obj_new(sizeof *c, &ws_wcontroller_type); + c->stream = ws_retain(s); + c->high_water_mark = high_water_mark; + s->controller = c; /* the stream's reference */ + ws_writable_update_backpressure(c, s); + return c; +} + +static WsWritable *ws_writable_alloc(void) { + WsWritable *s = ws_obj_new(sizeof *s, &ws_writable_type); + s->state = WS_W_WRITABLE; + return s; +} + +/* The signal getter: the AbortController's signal, made on first read. */ +static ScrDyn *ws_wcontroller_signal(WsWController *c) { + if (!c->abort_controller) { + c->abort_controller = scr_fetch_abort_controller_new(); + if (c->aborted) { + ScrDyn *arg = c->aborted_reason; + ScrDyn *r = scr_dyn_invoke(c->abort_controller, "abort", &arg, 1, "abortController.abort"); + if (!r) return NULL; + scr_dyn_release(r); + } + } + ScrStr *key = scr_str_new("signal", 6); + ScrDyn *signal = scr_dyn_handle_key_get(c->abort_controller, key); + scr_str_release(key); + return signal; +} + +/* Bind must be called on a function: Function.prototype.bind on a start + * member that is not callable. */ +static void ws_bind_error(void) { + static const char message[] = "Bind must be called on a function"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); +} + +/* new WritableStream(sink, strategy): +1 handle, or NULL with the + * exception pending. */ +static ScrDyn *ws_writable_construct(ScrDyn *const *args, size_t argc) { + ScrDyn *sink = argc > 0 && args[0]->kind != SCR_DYN_UNDEF ? args[0] : NULL; + ScrDyn *strategy = argc > 1 && args[1]->kind != SCR_DYN_UNDEF ? args[1] : NULL; + ScrDyn *empty = NULL; + if (!sink || !strategy) empty = scr_dyn_new_obj(); + if (!sink) sink = empty; + if (!strategy) strategy = empty; + ScrDyn *result = NULL; + ScrDyn *type = NULL, *size = NULL, *hwm = NULL, *start = NULL, *write = NULL, *close = NULL, *abort = NULL; + if (!ws_validate_object(sink, "sink", false) || !ws_validate_object(strategy, "strategy", true)) goto done; + if (!(type = ws_get(sink, "type"))) goto done; + if (type->kind != SCR_DYN_UNDEF) { + ws_range_arg_value("type", type); + goto done; + } + if (!(size = ws_get(strategy, "size"))) goto done; + if (size->kind != SCR_DYN_UNDEF && !ws_validate_function(size, "strategy.size")) goto done; + if (!(hwm = ws_get(strategy, "highWaterMark"))) goto done; + double high_water_mark; + if (!ws_extract_high_water_mark(hwm, 1, &high_water_mark)) goto done; + if (!(start = ws_get(sink, "start"))) goto done; + if (!(write = ws_get(sink, "write"))) goto done; + if (!(close = ws_get(sink, "close"))) goto done; + if (!(abort = ws_get(sink, "abort"))) goto done; + bool has_start = scr_dyn_truthy(start), has_write = scr_dyn_truthy(write); + bool has_close = scr_dyn_truthy(close), has_abort = scr_dyn_truthy(abort); + if (has_start && !scr_dyn_is_callable(start)) { + ws_bind_error(); + goto done; + } + if (has_write && !ws_validate_function(write, "sink.write")) goto done; + if (has_close && !ws_validate_function(close, "sink.close")) goto done; + if (has_abort && !ws_validate_function(abort, "sink.abort")) goto done; + ws_install(); + WsWritable *s = ws_writable_alloc(); + WsWController *c = ws_writable_attach(s, high_water_mark); + c->sink = scr_dyn_retain(sink); + if (has_write) c->write_fn = scr_dyn_retain(write); + if (has_close) c->close_fn = scr_dyn_retain(close); + if (has_abort) c->abort_fn = scr_dyn_retain(abort); + if (size->kind != SCR_DYN_UNDEF) c->size_fn = scr_dyn_retain(size); + ScrDyn *start_result; + if (has_start) { + ScrDyn *controller = ws_wcontroller_box(c); + start_result = ws_call(start, sink, &controller, 1, "sink.start"); + scr_dyn_release(controller); + } else { + start_result = ws_undef(); + } + if (!start_result) { + ws_release(s); + goto done; + } + ws_wcontroller_started_with(c, start_result); + scr_dyn_release(start_result); + result = ws_writable_box(s); + ws_release(s); +done: + if (empty) scr_dyn_release(empty); + if (type) scr_dyn_release(type); + if (size) scr_dyn_release(size); + if (hwm) scr_dyn_release(hwm); + if (start) scr_dyn_release(start); + if (write) scr_dyn_release(write); + if (close) scr_dyn_release(close); + if (abort) scr_dyn_release(abort); + return result; +} + +/* ── TransformStream (lib/internal/webstreams/transformstream.js) ── */ + +typedef struct WsTransform WsTransform; +typedef struct WsTController WsTController; + +static ScrDyn *ws_promise_box(ScrPromise *p); + +struct WsTransform { + WsObj obj; + WsReadable *readable; /* +1 */ + WsWritable *writable; /* +1 */ + WsTController *controller; + bool backpressure; + /* The continuation slots that stand for [[backpressureChangePromise]]. */ + bool pull_pending; + bool pending_write_parked; + ScrDyn *pending_write_chunk; /* +1 */ +}; + +struct WsTController { + WsObj obj; + WsTransform *stream; /* +1 */ + bool algorithms_cleared; + ScrDyn *transformer; + ScrDyn *transform_fn; /* NULL = the default transform (enqueue) */ + ScrDyn *flush_fn; + ScrDyn *cancel_fn; + ScrPromise *finish_promise; /* +1 */ +}; + +static void ws_transform_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsTransform *t = obj; + ws_visit(t->readable, visit, ctx); + ws_visit(t->writable, visit, ctx); + ws_visit(t->controller, visit, ctx); + ws_visit(t->pending_write_chunk, visit, ctx); +} + +static void ws_transform_drop(void *obj, bool gc) { + WsTransform *t = obj; + ws_drop_obj(t->readable, gc); + ws_drop_obj(t->writable, gc); + ws_drop_obj(t->controller, gc); + ws_drop_dyn(t->pending_write_chunk, gc); +} + +static const WsObjType ws_transform_type = {ws_transform_trace, ws_transform_drop, "TransformStream", ws_transform_accessors}; + +static void ws_tcontroller_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsTController *c = obj; + ws_visit(c->stream, visit, ctx); + ws_visit(c->transformer, visit, ctx); + ws_visit(c->transform_fn, visit, ctx); + ws_visit(c->flush_fn, visit, ctx); + ws_visit(c->cancel_fn, visit, ctx); + ws_visit(c->finish_promise, visit, ctx); +} + +static void ws_tcontroller_drop(void *obj, bool gc) { + WsTController *c = obj; + ws_drop_obj(c->stream, gc); + ws_drop_dyn(c->transformer, gc); + ws_drop_dyn(c->transform_fn, gc); + ws_drop_dyn(c->flush_fn, gc); + ws_drop_dyn(c->cancel_fn, gc); + ws_drop_promise(c->finish_promise, gc); +} + +static const WsObjType ws_tcontroller_type = {ws_tcontroller_trace, ws_tcontroller_drop, "TransformStreamDefaultController", ws_tcontroller_accessors}; + +static ScrDyn *ws_transform_box(WsTransform *t) { return ws_box(t, SCR_DYNH_WS_TRANSFORM); } +static ScrDyn *ws_tcontroller_box(WsTController *c) { return ws_box(c, SCR_DYNH_WS_TCONTROLLER); } + +static void ws_tcontroller_clear_algorithms(WsTController *c) { + if (c->algorithms_cleared) return; + c->algorithms_cleared = true; + ScrDyn *transformer = c->transformer, *transform = c->transform_fn, *flush = c->flush_fn, *cancel = c->cancel_fn; + c->transformer = c->transform_fn = c->flush_fn = c->cancel_fn = NULL; + if (transformer) scr_dyn_release(transformer); + if (transform) scr_dyn_release(transform); + if (flush) scr_dyn_release(flush); + if (cancel) scr_dyn_release(cancel); +} + +/* The parked write's continuation when the writable side errored + * meanwhile: the write reaction rejects with its stored error. */ +typedef struct WsWriteReject { + WsObj obj; + WsWController *controller; + ScrDyn *error; +} WsWriteReject; + +static void ws_write_reject_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsWriteReject *r = obj; + ws_visit(r->controller, visit, ctx); + ws_visit(r->error, visit, ctx); +} + +static void ws_write_reject_drop(void *obj, bool gc) { + WsWriteReject *r = obj; + ws_drop_obj(r->controller, gc); + ws_drop_dyn(r->error, gc); +} + +static const WsObjType ws_write_reject_type = {ws_write_reject_trace, ws_write_reject_drop}; + +static void ws_write_reject_step(void *ctx, ScrDyn *unused) { + (void)unused; + WsWriteReject *r = ctx; + ws_write_rejected(r->controller, r->error); +} + +static ScrPromise *ws_tcontroller_perform_transform(WsTController *c, ScrDyn *chunk); + +/* writeContinuation: completes the parked write. */ +static void ws_transform_write_continuation(void *ctx, ScrDyn *unused) { + (void)unused; + WsTransform *t = ctx; + ScrDyn *chunk = t->pending_write_chunk; + t->pending_write_parked = false; + t->pending_write_chunk = NULL; + WsWritable *w = t->writable; + WsWController *wc = w->controller; + if (w->state == WS_W_ERRORING) { + WsWriteReject *r = ws_obj_new(sizeof *r, &ws_write_reject_type); + r->controller = ws_retain(wc); + r->error = ws_or_undef(w->stored_error); + ws_queue_microtask(ws_write_reject_step, r); + if (chunk) scr_dyn_release(chunk); + return; + } + ScrDyn *value = chunk ? chunk : ws_undef(); + ScrPromise *result = ws_tcontroller_perform_transform(t->controller, value); + scr_dyn_release(value); + ws_then(result, ws_write_fulfilled, ws_write_rejected, ws_retain(wc), false); + scr_promise_release(result); +} + +/* transformStreamSetBackpressure. */ +static void ws_transform_set_backpressure(WsTransform *t, bool backpressure) { + t->backpressure = backpressure; + if (backpressure) { + if (t->pull_pending) { + t->pull_pending = false; + ws_queue_microtask(ws_pull_fulfilled, ws_retain(t->readable->controller)); + } + } else if (t->pending_write_parked) { + ws_queue_microtask(ws_transform_write_continuation, ws_retain(t)); + } +} + +static void ws_transform_unblock_write(WsTransform *t) { + if (t->backpressure) ws_transform_set_backpressure(t, false); +} + +static void ws_transform_error_writable_and_unblock_write(WsTransform *t, ScrDyn *error) { + ws_tcontroller_clear_algorithms(t->controller); + ws_wcontroller_error_if_needed(t->writable->controller, error); + ws_transform_unblock_write(t); +} + +static void ws_transform_error(WsTransform *t, ScrDyn *error) { + ws_rcontroller_error(t->readable->controller, error); + ws_transform_error_writable_and_unblock_write(t, error); +} + +/* transformStreamDefaultControllerEnqueue: false with the exception + * pending. */ +static bool ws_tcontroller_enqueue(WsTController *c, ScrDyn *chunk) { + WsTransform *t = c->stream; + WsRController *rc = t->readable->controller; + if (!ws_rcontroller_can_close_or_enqueue(rc)) { + ws_state_error("Unable to enqueue"); + return false; + } + if (!ws_rcontroller_enqueue(rc, chunk)) { + ScrDyn *error = ws_take_pending(); + ws_transform_error_writable_and_unblock_write(t, error); + scr_dyn_release(error); + scr_dyn_throw(ws_or_undef(t->readable->stored_error)); + return false; + } + bool backpressure = !ws_rcontroller_should_call_pull(rc); + if (backpressure != t->backpressure) ws_transform_set_backpressure(t, true); + return true; +} + +static void ws_tcontroller_terminate(WsTController *c) { + WsTransform *t = c->stream; + ws_retain(t); + ws_rcontroller_close(t->readable->controller); + ScrDyn *error = ws_state_error_value("TransformStream has been terminated"); + ws_transform_error_writable_and_unblock_write(t, error); + scr_dyn_release(error); + ws_release(t); +} + +/* The perform-transform rejection step: errors the stream, then rethrows + * so the derived promise rejects with the error. */ +static void ws_perform_transform_rejected(void *ctx, ScrDyn *error) { + WsTController *c = ctx; + ws_transform_error(c->stream, error); + scr_dyn_throw(scr_dyn_retain(error)); +} + +/* transformStreamDefaultControllerPerformTransform: +1 promise. */ +static ScrPromise *ws_tcontroller_perform_transform(WsTController *c, ScrDyn *chunk) { + if (c->algorithms_cleared) return scr_promise_retain(ws_resolved_singleton()); + ScrDyn *result; + if (c->transform_fn) { + ScrDyn *controller = ws_tcontroller_box(c); + ScrDyn *transform = scr_dyn_retain(c->transform_fn); + ScrDyn *transformer = scr_dyn_retain(c->transformer); + ScrDyn *args[2] = {chunk, controller}; + result = ws_call(transform, transformer, args, 2, "transformer.transform"); + scr_dyn_release(transform); + scr_dyn_release(transformer); + scr_dyn_release(controller); + } else { + /* defaultTransformAlgorithm */ + result = ws_tcontroller_enqueue(c, chunk) ? ws_undef() : NULL; + } + ScrPromise *p; + if (!result) { + p = ws_promise_rejected_pending(); + } else if (!ws_is_object(result)) { + p = scr_promise_retain(ws_resolved_singleton()); + } else { + p = ws_promise_resolve(result); + if (!p) p = ws_promise_rejected_pending(); + } + if (result) scr_dyn_release(result); + ScrPromise *derived = ws_then(p, NULL, ws_perform_transform_rejected, ws_retain(c), true); + scr_promise_release(p); + return derived; +} + +/* The steps the sink close, sink abort and source cancel algorithms run on + * their callback's promise. */ +typedef enum { WS_FINISH_CLOSE, WS_FINISH_ABORT, WS_FINISH_CANCEL } WsFinishKind; + +typedef struct WsFinish { + WsObj obj; + WsTransform *stream; + ScrPromise *finish; /* +1 */ + ScrDyn *reason; /* +1, the abort or cancel reason */ + WsFinishKind kind; +} WsFinish; + +static void ws_finish_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsFinish *f = obj; + ws_visit(f->stream, visit, ctx); + ws_visit(f->finish, visit, ctx); + ws_visit(f->reason, visit, ctx); +} + +static void ws_finish_drop(void *obj, bool gc) { + WsFinish *f = obj; + ws_drop_obj(f->stream, gc); + ws_drop_promise(f->finish, gc); + ws_drop_dyn(f->reason, gc); +} + +static const WsObjType ws_finish_type = {ws_finish_trace, ws_finish_drop}; + +static void ws_finish_fulfilled(void *ctx, ScrDyn *unused) { + (void)unused; + WsFinish *f = ctx; + WsTransform *t = f->stream; + if (f->kind == WS_FINISH_CANCEL) { + WsWritable *w = t->writable; + if (w->state == WS_W_ERRORED) { + ws_promise_reject_with(f->finish, w->stored_error); + return; + } + ws_wcontroller_error_if_needed(w->controller, f->reason); + ws_transform_unblock_write(t); + ws_promise_fulfill_undefined(f->finish); + return; + } + WsReadable *r = t->readable; + if (r->state == WS_STATE_ERRORED) { + ws_promise_reject_with(f->finish, r->stored_error); + return; + } + if (f->kind == WS_FINISH_CLOSE) ws_rcontroller_close(r->controller); + else ws_rcontroller_error(r->controller, f->reason); + ws_promise_fulfill_undefined(f->finish); +} + +static void ws_finish_rejected(void *ctx, ScrDyn *error) { + WsFinish *f = ctx; + WsTransform *t = f->stream; + if (f->kind == WS_FINISH_CANCEL) { + ws_wcontroller_error_if_needed(t->writable->controller, error); + ws_transform_unblock_write(t); + } else { + ws_rcontroller_error(t->readable->controller, error); + } + ws_reject(f->finish, error); +} + +/* A transformer callback wrapped by createPromiseCallback1Param, or the + * nonOp default: +1 promise. */ +static ScrPromise *ws_tcontroller_callback(WsTController *c, ScrDyn *fn, ScrDyn *arg, const char *what) { + if (!fn) return ws_promise_resolved_undefined(); + ScrDyn *callee = scr_dyn_retain(fn); + ScrDyn *transformer = scr_dyn_retain(c->transformer); + ScrDyn *r = ws_call(callee, transformer, &arg, 1, what); + scr_dyn_release(callee); + scr_dyn_release(transformer); + if (!r) return ws_promise_rejected_pending(); + ScrPromise *p = ws_promise_resolving(r); + scr_dyn_release(r); + return p; +} + +/* The finish promise of the transform's close, abort or cancel: +1. */ +static ScrPromise *ws_transform_finish(WsTransform *t, WsFinishKind kind, ScrDyn *reason) { + WsTController *c = t->controller; + if (c->finish_promise) return scr_promise_retain(c->finish_promise); + c->finish_promise = scr_promise_new(); + ScrPromise *callback; + if (kind == WS_FINISH_CLOSE) { + ScrDyn *controller = ws_tcontroller_box(c); + callback = ws_tcontroller_callback(c, c->flush_fn, controller, "transformer.flush"); + scr_dyn_release(controller); + } else { + ScrDyn *arg = ws_or_undef(reason); + callback = ws_tcontroller_callback(c, c->cancel_fn, arg, "transformer.cancel"); + scr_dyn_release(arg); + } + ws_tcontroller_clear_algorithms(c); + WsFinish *f = ws_obj_new(sizeof *f, &ws_finish_type); + f->stream = ws_retain(t); + f->finish = scr_promise_retain(c->finish_promise); + f->reason = ws_or_undef(reason); + f->kind = kind; + ws_then(callback, ws_finish_fulfilled, ws_finish_rejected, f, false); + scr_promise_release(callback); + return scr_promise_retain(c->finish_promise); +} + +/* The writable side's algorithms. */ +static ScrDyn *ws_transform_sink_write(void *ctx, WsWController *wc, ScrDyn *chunk) { + (void)wc; + WsTransform *t = ctx; + if (t->backpressure) { + t->pending_write_parked = true; + t->pending_write_chunk = scr_dyn_retain(chunk); + return WS_PARKED; + } + return ws_promise_box(ws_tcontroller_perform_transform(t->controller, chunk)); +} + +static ScrPromise *ws_transform_sink_close(void *ctx, WsWController *wc) { + (void)wc; + return ws_transform_finish(ctx, WS_FINISH_CLOSE, NULL); +} + +/* `async function transformStreamDefaultSinkAbortAlgorithm`: the result + * adopts the finish promise. */ +static ScrPromise *ws_transform_sink_abort(void *ctx, WsWController *wc, ScrDyn *reason) { + (void)wc; + ScrPromise *finish = ws_transform_finish(ctx, WS_FINISH_ABORT, reason); + ScrDyn *box = ws_promise_box(finish); + ScrPromise *result = ws_promise_resolving(box); + scr_dyn_release(box); + return result; +} + +static const WsSinkOps ws_transform_sink_ops = {ws_transform_sink_write, ws_transform_sink_close, + ws_transform_sink_abort}; + +/* The readable side's algorithms. */ +static ScrDyn *ws_transform_source_pull(void *ctx, WsRController *rc) { + (void)rc; + WsTransform *t = ctx; + ws_transform_set_backpressure(t, false); + t->pull_pending = true; + return WS_PARKED; +} + +static ScrPromise *ws_transform_source_cancel(void *ctx, WsRController *rc, ScrDyn *reason) { + (void)rc; + return ws_transform_finish(ctx, WS_FINISH_CANCEL, reason); +} + +static const WsSourceOps ws_transform_source_ops = {ws_transform_source_pull, ws_transform_source_cancel}; + +/* `start` called through FunctionPrototypeCall: a value that is not + * callable throws its TypeError. */ +static void ws_function_call_error(void) { + static const char message[] = "FunctionPrototypeCall is not a function"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); +} + +/* new TransformStream(transformer, writableStrategy, readableStrategy): + * +1 handle, or NULL with the exception pending. */ +static ScrDyn *ws_transform_construct(ScrDyn *const *args, size_t argc) { + ScrDyn *transformer = argc > 0 && args[0]->kind != SCR_DYN_UNDEF ? args[0] : NULL; + ScrDyn *wstrategy = argc > 1 && args[1]->kind != SCR_DYN_UNDEF ? args[1] : NULL; + ScrDyn *rstrategy = argc > 2 && args[2]->kind != SCR_DYN_UNDEF ? args[2] : NULL; + ScrDyn *empty = NULL; + if (!transformer || !wstrategy || !rstrategy) empty = scr_dyn_new_obj(); + if (!transformer) transformer = empty; + if (!wstrategy) wstrategy = empty; + if (!rstrategy) rstrategy = empty; + ScrDyn *result = NULL; + ScrDyn *rtype = NULL, *wtype = NULL, *start = NULL, *rhwm = NULL, *rsize = NULL, *whwm = NULL, *wsize = NULL; + ScrDyn *transform = NULL, *flush = NULL, *cancel = NULL; + if (!ws_validate_object(transformer, "transformer", false) || + !ws_validate_object(wstrategy, "writableStrategy", true) || + !ws_validate_object(rstrategy, "readableStrategy", true)) + goto done; + if (!(rtype = ws_get(transformer, "readableType"))) goto done; + if (!(wtype = ws_get(transformer, "writableType"))) goto done; + if (!(start = ws_get(transformer, "start"))) goto done; + if (rtype->kind != SCR_DYN_UNDEF) { + ws_range_arg_value("transformer.readableType", rtype); + goto done; + } + if (wtype->kind != SCR_DYN_UNDEF) { + ws_range_arg_value("transformer.writableType", wtype); + goto done; + } + if (!(rhwm = ws_get(rstrategy, "highWaterMark"))) goto done; + if (!(rsize = ws_get(rstrategy, "size"))) goto done; + if (!(whwm = ws_get(wstrategy, "highWaterMark"))) goto done; + if (!(wsize = ws_get(wstrategy, "size"))) goto done; + double readable_hwm, writable_hwm; + if (!ws_extract_high_water_mark(rhwm, 0, &readable_hwm)) goto done; + if (rsize->kind != SCR_DYN_UNDEF && !ws_validate_function(rsize, "strategy.size")) goto done; + if (!ws_extract_high_water_mark(whwm, 1, &writable_hwm)) goto done; + if (wsize->kind != SCR_DYN_UNDEF && !ws_validate_function(wsize, "strategy.size")) goto done; + ws_install(); + WsTransform *t = ws_obj_new(sizeof *t, &ws_transform_type); + ScrPromise *start_promise = scr_promise_new(); + ScrDyn *start_box = scr_dyn_new_promise(start_promise); + /* initializeTransformStream: the writable side first. */ + t->writable = ws_writable_alloc(); + WsWController *wc = ws_writable_attach(t->writable, writable_hwm); + wc->native = &ws_transform_sink_ops; + wc->native_ctx = ws_retain(t); + if (wsize->kind != SCR_DYN_UNDEF) wc->size_fn = scr_dyn_retain(wsize); + ws_wcontroller_started_with(wc, start_box); + t->readable = ws_readable_alloc(); + WsRController *rc = ws_rcontroller_attach(t->readable, readable_hwm); + rc->native = &ws_transform_source_ops; + rc->native_ctx = ws_retain(t); + if (rsize->kind != SCR_DYN_UNDEF) rc->size_fn = scr_dyn_retain(rsize); + ws_rcontroller_started_with(rc, start_box); + scr_dyn_release(start_box); + ws_transform_set_backpressure(t, true); + /* setupTransformStreamDefaultControllerFromTransformer */ + WsTController *c = ws_obj_new(sizeof *c, &ws_tcontroller_type); + c->stream = ws_retain(t); + t->controller = c; /* the stream's reference */ + bool ok = (transform = ws_get(transformer, "transform")) && (flush = ws_get(transformer, "flush")) && + (cancel = ws_get(transformer, "cancel")); + if (ok && scr_dyn_truthy(transform)) ok = ws_validate_function(transform, "transformer.transform"); + if (ok && scr_dyn_truthy(flush)) ok = ws_validate_function(flush, "transformer.flush"); + if (ok && scr_dyn_truthy(cancel)) ok = ws_validate_function(cancel, "transformer.cancel"); + if (!ok) { + scr_promise_release(start_promise); + ws_release(t); + goto done; + } + c->transformer = scr_dyn_retain(transformer); + if (scr_dyn_truthy(transform)) c->transform_fn = scr_dyn_retain(transform); + if (scr_dyn_truthy(flush)) c->flush_fn = scr_dyn_retain(flush); + if (scr_dyn_truthy(cancel)) c->cancel_fn = scr_dyn_retain(cancel); + if (start->kind != SCR_DYN_UNDEF) { + if (!scr_dyn_is_callable(start)) { + ws_function_call_error(); + scr_promise_release(start_promise); + ws_release(t); + goto done; + } + ScrDyn *controller = ws_tcontroller_box(c); + ScrDyn *value = ws_call(start, transformer, &controller, 1, "transformer.start"); + scr_dyn_release(controller); + if (!value) { + scr_promise_release(start_promise); + ws_release(t); + goto done; + } + /* startPromise.resolve(result): a thenable result adopts through its + * resolve job, so both sides start when it settles. */ + scr_dyn_promise_resolve_with(start_promise, value); + scr_dyn_release(value); + } else { + ws_promise_fulfill_undefined(start_promise); + } + scr_promise_release(start_promise); + result = ws_transform_box(t); + ws_release(t); +done: + if (empty) scr_dyn_release(empty); + ScrDyn *gets[] = {rtype, wtype, start, rhwm, rsize, whwm, wsize, transform, flush, cancel}; + for (size_t i = 0; i < sizeof gets / sizeof gets[0]; i++) + if (gets[i]) scr_dyn_release(gets[i]); + return result; +} + +/* ── pipeTo (readableStreamPipeTo) ────────────────────────────────── */ + +/* writableStreamDefaultWriterWriteWithRequest: the pipe's write of one + * chunk, tracked by its shared request; a write that cannot proceed + * latches the failure on it. */ +static void ws_writer_write_with_tracker(WsWriter *w, ScrDyn *chunk, WsPipe *tracker); + +/* writableStreamDefaultWriterCloseWithErrorPropagation: +1 promise. */ +static ScrPromise *ws_writer_close_with_error_propagation(WsWriter *w) { + WsWritable *s = w->stream; + if (s->close_queued_or_inflight || s->state == WS_W_CLOSED) return ws_promise_resolved_undefined(); + if (s->state == WS_W_ERRORED) return ws_promise_rejected_with(s->stored_error); + return ws_writable_close(s); +} + +typedef enum { + WS_PIPE_ABORT_DEST, + WS_PIPE_CANCEL_SOURCE, + WS_PIPE_CLOSE_DEST, + WS_PIPE_SIGNAL, +} WsPipeAction; + +struct WsPipe { + WsObj obj; + WsReadable *source; + WsWritable *dest; + WsReader *reader; + WsWriter *writer; + bool prevent_close, prevent_abort, prevent_cancel; + ScrDyn *signal; /* +1, or NULL */ + ScrDyn *disposable; /* +1: the abort listener's dispose */ + bool shutting_down; + ScrPromise *promise; /* the pipeTo result, +1 */ + /* The shared write request. */ + size_t pending; + bool failed; + ScrDyn *failure; /* +1 */ + ScrPromise *stall; /* +1 while armed */ + ScrDyn *pending_chunk; + ScrPromise *ready_promise; /* the ready hook's forever-pending promise */ + /* The shutdown in progress. */ + WsPipeAction action; + bool rejected; + ScrDyn *original_error; /* +1, or NULL for undefined */ + ScrDyn *action_error; /* the action's argument (+1) */ +}; + +static void ws_pipe_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsPipe *p = obj; + ws_visit(p->source, visit, ctx); + ws_visit(p->dest, visit, ctx); + ws_visit(p->reader, visit, ctx); + ws_visit(p->writer, visit, ctx); + ws_visit(p->signal, visit, ctx); + ws_visit(p->disposable, visit, ctx); + ws_visit(p->promise, visit, ctx); + ws_visit(p->failure, visit, ctx); + ws_visit(p->stall, visit, ctx); + ws_visit(p->pending_chunk, visit, ctx); + ws_visit(p->ready_promise, visit, ctx); + ws_visit(p->original_error, visit, ctx); + ws_visit(p->action_error, visit, ctx); +} + +static void ws_pipe_drop(void *obj, bool gc) { + WsPipe *p = obj; + ws_drop_obj(p->source, gc); + ws_drop_obj(p->dest, gc); + ws_drop_obj(p->reader, gc); + ws_drop_obj(p->writer, gc); + ws_drop_dyn(p->signal, gc); + ws_drop_dyn(p->disposable, gc); + ws_drop_promise(p->promise, gc); + ws_drop_dyn(p->failure, gc); + ws_drop_promise(p->stall, gc); + ws_drop_dyn(p->pending_chunk, gc); + ws_drop_promise(p->ready_promise, gc); + ws_drop_dyn(p->original_error, gc); + ws_drop_dyn(p->action_error, gc); +} + +static const WsObjType ws_pipe_type = {ws_pipe_trace, ws_pipe_drop}; + +static void ws_pipe_tracker_resolve(WsPipe *p) { + if (--p->pending == 0 && p->stall) { + ScrPromise *stall = p->stall; + p->stall = NULL; + if (p->failed) ws_reject(stall, p->failure); + else ws_promise_fulfill_undefined(stall); + scr_promise_release(stall); + } +} + +static void ws_pipe_tracker_reject(WsPipe *p, ScrDyn *error) { + p->pending--; + p->failed = true; + if (p->failure) scr_dyn_release(p->failure); + p->failure = scr_dyn_retain(error); + if (p->stall) { + ScrPromise *stall = p->stall; + p->stall = NULL; + ws_reject(stall, error); + scr_promise_release(stall); + } +} + +static void ws_writer_write_with_tracker(WsWriter *w, ScrDyn *chunk, WsPipe *tracker) { + WsWritable *s = w->stream; + ws_retain(s); + WsWController *c = s->controller; + ScrDyn *size = ws_wcontroller_chunk_size(c, chunk); + ScrDyn *failure = NULL; + if (s != w->stream) failure = ws_state_error_value("Mismatched WritableStreams"); + else if (s->state == WS_W_ERRORED) failure = ws_or_undef(s->stored_error); + else if (s->close_queued_or_inflight || s->state == WS_W_CLOSED) + failure = ws_state_error_value("WritableStream is closed"); + else if (s->state == WS_W_ERRORING) failure = ws_or_undef(s->stored_error); + if (failure) { + tracker->failed = true; + if (tracker->failure) scr_dyn_release(tracker->failure); + tracker->failure = failure; + } else { + ws_requests_push(s, (WsWriteRequest){NULL, ws_retain(tracker)}); + tracker->pending++; + ws_wcontroller_write(c, chunk, size); + } + if (size) scr_dyn_release(size); + ws_release(s); +} + +static void ws_pipe_finalize(WsPipe *p, bool rejected, ScrDyn *error) { + ws_retain(p); + if (p->writer->stream) ws_writer_release_lock(p->writer); + if (p->reader->stream) ws_reader_generic_release(p->reader); + if (p->disposable) { + ScrDyn *dispose = p->disposable; + p->disposable = NULL; + ScrDyn *r = scr_dyn_call(dispose, NULL, 0, "[Symbol.dispose]"); + if (r) scr_dyn_release(r); + else if (scr_exc_pending()) scr_exc_clear(); + scr_dyn_release(dispose); + } + if (rejected) ws_promise_reject_with(p->promise, error); + else ws_promise_fulfill_undefined(p->promise); + ws_release(p); +} + +static void ws_pipe_finalize_original(void *ctx, ScrDyn *unused) { + (void)unused; + WsPipe *p = ctx; + ws_pipe_finalize(p, p->rejected, p->original_error); +} + +static void ws_pipe_finalize_error(void *ctx, ScrDyn *error) { ws_pipe_finalize(ctx, true, error); } + +/* waitForPendingWrites: +1 promise. */ +static ScrPromise *ws_pipe_wait_for_pending_writes(WsPipe *p) { + if (p->pending == 0) return p->failed ? ws_promise_rejected_with(p->failure) : ws_promise_resolved_undefined(); + if (p->stall) scr_promise_release(p->stall); + p->stall = scr_promise_new(); + return scr_promise_retain(p->stall); +} + +/* Promise.all over the abort algorithm's actions, through the primordial + * SafePromiseAll: each entry resolves a promise of its own, and the + * combined result settles a promise the caller's then observes. */ +typedef struct WsPipeAll { + WsObj obj; + ScrPromise *all; /* +1 */ + size_t remaining; + bool settled; +} WsPipeAll; + +static void ws_pipe_all_trace(void *obj, ScrTraceVisit visit, void *ctx) { ws_visit(((WsPipeAll *)obj)->all, visit, ctx); } + +static void ws_pipe_all_drop(void *obj, bool gc) { ws_drop_promise(((WsPipeAll *)obj)->all, gc); } + +static const WsObjType ws_pipe_all_type = {ws_pipe_all_trace, ws_pipe_all_drop}; + +static void ws_pipe_all_fulfilled(void *ctx, ScrDyn *unused) { + (void)unused; + WsPipeAll *a = ctx; + if (a->settled || --a->remaining != 0) return; + a->settled = true; + ws_promise_fulfill_undefined(a->all); +} + +static void ws_pipe_all_rejected(void *ctx, ScrDyn *error) { + WsPipeAll *a = ctx; + if (a->settled) return; + a->settled = true; + ws_reject(a->all, error); +} + +/* The shutdown action's promise: +1. */ +static ScrPromise *ws_pipe_run_action(WsPipe *p) { + ScrDyn *error = p->action_error; + switch (p->action) { + case WS_PIPE_ABORT_DEST: return ws_writable_abort(p->dest, error); + case WS_PIPE_CANCEL_SOURCE: return ws_readable_cancel(p->source, error); + case WS_PIPE_CLOSE_DEST: return ws_writer_close_with_error_propagation(p->writer); + case WS_PIPE_SIGNAL: break; + } + ScrPromise *entries[2]; + size_t n = 0; + if (!p->prevent_abort) + entries[n++] = p->dest->state == WS_W_WRITABLE ? ws_writable_abort(p->dest, error) : ws_promise_resolved_undefined(); + if (!p->prevent_cancel) + entries[n++] = p->source->state == WS_STATE_READABLE ? ws_readable_cancel(p->source, error) + : ws_promise_resolved_undefined(); + WsPipeAll *a = ws_obj_new(sizeof *a, &ws_pipe_all_type); + a->all = scr_promise_new(); + a->remaining = n; + if (n == 0) { + a->settled = true; + ws_promise_fulfill_undefined(a->all); + } + for (size_t i = 0; i < n; i++) { + ScrDyn *box = ws_promise_box(entries[i]); + ScrPromise *own = ws_promise_resolving(box); + scr_dyn_release(box); + ws_then(own, ws_pipe_all_fulfilled, ws_pipe_all_rejected, ws_retain(a), false); + scr_promise_release(own); + } + ScrPromise *result = ws_then_void(a->all); + ws_release(a); + return result; +} + +static void ws_pipe_complete(void *ctx, ScrDyn *unused) { + (void)unused; + WsPipe *p = ctx; + ScrPromise *action = ws_pipe_run_action(p); + ws_then(action, ws_pipe_finalize_original, ws_pipe_finalize_error, ws_retain(p), false); + scr_promise_release(action); +} + +static void ws_pipe_shutdown_with_action(WsPipe *p, WsPipeAction action, bool rejected, ScrDyn *original_error, + ScrDyn *action_error) { + if (p->shutting_down) return; + p->shutting_down = true; + p->action = action; + p->rejected = rejected; + p->original_error = original_error ? scr_dyn_retain(original_error) : NULL; + p->action_error = ws_or_undef(action_error); + if (p->dest->state == WS_W_WRITABLE && !p->dest->close_queued_or_inflight) { + ScrPromise *wait = ws_pipe_wait_for_pending_writes(p); + ws_then(wait, ws_pipe_complete, ws_pipe_finalize_error, ws_retain(p), false); + scr_promise_release(wait); + return; + } + ws_pipe_complete(p, NULL); +} + +static void ws_pipe_shutdown(WsPipe *p, bool rejected, ScrDyn *error) { + if (p->shutting_down) return; + p->shutting_down = true; + p->rejected = rejected; + p->original_error = error ? scr_dyn_retain(error) : NULL; + if (p->dest->state == WS_W_WRITABLE && !p->dest->close_queued_or_inflight) { + ScrPromise *wait = ws_pipe_wait_for_pending_writes(p); + ws_then(wait, ws_pipe_finalize_original, ws_pipe_finalize_error, ws_retain(p), false); + scr_promise_release(wait); + return; + } + ws_pipe_finalize(p, rejected, error); +} + +static void ws_pipe_abort_algorithm(WsPipe *p) { + ScrDyn *error = ws_get(p->signal, "reason"); + if (!error) { + error = ws_take_pending(); + } + ws_pipe_shutdown_with_action(p, WS_PIPE_SIGNAL, true, error, error); + scr_dyn_release(error); +} + +static void ws_pipe_pump(WsPipe *p); + +static void ws_pipe_pump_hook(void *ctx) { ws_pipe_pump(ctx); } + +static void ws_pipe_pump_step(void *ctx, ScrDyn *unused) { + (void)unused; + ws_pipe_pump(ctx); +} + +static void ws_pipe_park_on_ready(WsPipe *p) { + if (!p->ready_promise) p->ready_promise = scr_promise_new(); + WsRecord *ready = &p->writer->ready; + ws_record_set(ready, scr_promise_retain(p->ready_promise), true); + ready->hook = ws_pipe_pump_hook; + ready->hook_ctx = p; +} + +static void ws_pipe_forward_chunk(void *ctx, ScrDyn *unused) { + (void)unused; + WsPipe *p = ctx; + ScrDyn *chunk = p->pending_chunk; + p->pending_chunk = NULL; + ws_writer_write_with_tracker(p->writer, chunk, p); + scr_dyn_release(chunk); + ws_pipe_pump(p); +} + +static void ws_pipe_read_chunk(WsReadRequest *q, ScrDyn *chunk) { + WsPipe *p = q->ctx; + if (p->pending_chunk) scr_dyn_release(p->pending_chunk); + p->pending_chunk = scr_dyn_retain(chunk); + ws_queue_microtask(ws_pipe_forward_chunk, p); /* the request's reference moves */ + free(q); +} + +static void ws_pipe_read_done(WsReadRequest *q) { + ws_release(q->ctx); + free(q); +} + +static void ws_pipe_read_error(WsReadRequest *q, ScrDyn *error) { + (void)error; + ws_pipe_read_done(q); +} + +static const WsReadRequestOps ws_pipe_read_ops = {ws_pipe_read_chunk, ws_pipe_read_done, ws_pipe_read_error}; + +static void ws_pipe_pump(WsPipe *p) { + if (p->shutting_down) return; + WsWritable *dest = p->dest; + if (dest->backpressure) { + ws_pipe_park_on_ready(p); + return; + } + WsReadable *source = p->source; + WsRController *c = source->controller; + if (source->state == WS_STATE_READABLE && c->queue.length > 0) { + ws_retain(p); + while (c->queue.length > 0) { + if (p->shutting_down) { + ws_release(p); + return; + } + double size; + ScrDyn *chunk = ws_queue_shift(&c->queue, &size); + c->queue_total_size = fmax(0, c->queue_total_size - size); + if (c->close_requested && !c->queue.length) { + ws_rcontroller_clear_algorithms(c); + ws_readable_close(source); + } + ws_writer_write_with_tracker(p->writer, chunk, p); + scr_dyn_release(chunk); + if (dest->backpressure) break; + if (dest->state != WS_W_WRITABLE || dest->close_queued_or_inflight) break; + } + ws_rcontroller_call_pull_if_needed(c); + if (source->state == WS_STATE_CLOSED) { + ws_release(p); + return; + } + if (dest->backpressure) { + ws_pipe_park_on_ready(p); + ws_release(p); + return; + } + ws_queue_microtask(ws_pipe_pump_step, p); /* the reference moves */ + return; + } + WsReadRequest *q = ws_alloc(sizeof *q); + q->ops = &ws_pipe_read_ops; + q->ctx = ws_retain(p); + ws_reader_read_request(p->reader, q); +} + +/* The watchers on the reader's and writer's closed promises. */ +static void ws_pipe_source_errored(void *ctx, ScrDyn *error) { + WsPipe *p = ctx; + if (!p->prevent_abort) ws_pipe_shutdown_with_action(p, WS_PIPE_ABORT_DEST, true, error, error); + else ws_pipe_shutdown(p, true, error); +} + +static void ws_pipe_dest_errored(void *ctx, ScrDyn *error) { + WsPipe *p = ctx; + if (!p->prevent_cancel) ws_pipe_shutdown_with_action(p, WS_PIPE_CANCEL_SOURCE, true, error, error); + else ws_pipe_shutdown(p, true, error); +} + +static void ws_pipe_source_closed(void *ctx, ScrDyn *unused) { + (void)unused; + WsPipe *p = ctx; + if (!p->prevent_close) ws_pipe_shutdown_with_action(p, WS_PIPE_CLOSE_DEST, false, NULL, NULL); + else ws_pipe_shutdown(p, false, NULL); +} + +static void ws_pipe_ignore(void *ctx, ScrDyn *unused) { + (void)ctx; + (void)unused; +} + +static ScrDyn *ws_pipe_abort_listener(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + WsPipe *p = scr_box_get_ref(closure->caps[0]); + ws_pipe_abort_algorithm(p); + ws_release(p); + return ws_undef(); +} + +/* readableStreamPipeTo: +1 promise. The streams are unlocked. */ +static ScrPromise *ws_readable_pipe_to(WsReadable *source, WsWritable *dest, bool prevent_close, bool prevent_abort, + bool prevent_cancel, ScrDyn *signal) { + WsReader *reader = ws_reader_new(source); + if (!reader) return ws_promise_rejected_pending(); + WsWriter *writer = ws_writer_new(dest); + if (!writer) { + ws_release(reader); + return ws_promise_rejected_pending(); + } + source->disturbed = true; + WsPipe *p = ws_obj_new(sizeof *p, &ws_pipe_type); + p->source = ws_retain(source); + p->dest = ws_retain(dest); + p->reader = reader; + p->writer = writer; + p->prevent_close = prevent_close; + p->prevent_abort = prevent_abort; + p->prevent_cancel = prevent_cancel; + p->promise = scr_promise_new(); + ScrPromise *result = scr_promise_retain(p->promise); + if (signal) { + p->signal = scr_dyn_retain(signal); + ScrDyn *aborted = ws_get(signal, "aborted"); + bool is_aborted = aborted && scr_dyn_truthy(aborted); + if (aborted) scr_dyn_release(aborted); + else if (scr_exc_pending()) scr_exc_clear(); + if (is_aborted) { + ws_pipe_abort_algorithm(p); + ws_release(p); + return result; + } + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new_obj(ws_retain, ws_release, ws_obj_trace); + scr_box_set_ref(closure->caps[0], ws_retain(p)); + ScrDyn *listener = scr_dyn_new_func(closure, ws_pipe_abort_listener, 0, "", "abortAlgorithm"); + p->disposable = scr_events_add_abort_listener(signal, listener); + scr_dyn_release(listener); + if (!p->disposable && scr_exc_pending()) scr_exc_clear(); + } + ws_pipe_pump(p); + if (source->state == WS_STATE_ERRORED) ws_pipe_source_errored(p, source->stored_error); + else ws_then(ws_reader_closed_promise(reader), NULL, ws_pipe_source_errored, ws_retain(p), false); + if (dest->state == WS_W_ERRORED) ws_pipe_dest_errored(p, dest->stored_error); + else ws_then(ws_writer_closed_promise(writer), NULL, ws_pipe_dest_errored, ws_retain(p), false); + if (source->state == WS_STATE_CLOSED) ws_pipe_source_closed(p, NULL); + else ws_then(ws_reader_closed_promise(reader), ws_pipe_source_closed, ws_pipe_ignore, ws_retain(p), false); + if (dest->close_queued_or_inflight || dest->state == WS_W_CLOSED) { + ScrDyn *error = ws_state_error_value("Destination WritableStream is closed"); + if (!prevent_cancel) ws_pipe_shutdown_with_action(p, WS_PIPE_CANCEL_SOURCE, true, error, error); + else ws_pipe_shutdown(p, true, error); + scr_dyn_release(error); + } + ws_release(p); + return result; +} + +/* pipeTo(destination, options) and pipeThrough(transform, options): the + * options read in Node's order, and the checks before the pipe starts. + * false with the exception pending. */ +static bool ws_pipe_options(ScrDyn *options, bool *prevent_close, bool *prevent_abort, bool *prevent_cancel, + ScrDyn **signal_out) { + *signal_out = NULL; + if (!ws_validate_object(options, "options", true)) return false; + ScrDyn *abort = ws_get(options, "preventAbort"); + if (!abort) return false; + ScrDyn *cancel = ws_get(options, "preventCancel"); + if (!cancel) { + scr_dyn_release(abort); + return false; + } + ScrDyn *close = ws_get(options, "preventClose"); + if (!close) { + scr_dyn_release(abort); + scr_dyn_release(cancel); + return false; + } + ScrDyn *signal = ws_get(options, "signal"); + *prevent_abort = scr_dyn_truthy(abort); + *prevent_cancel = scr_dyn_truthy(cancel); + *prevent_close = scr_dyn_truthy(close); + scr_dyn_release(abort); + scr_dyn_release(cancel); + scr_dyn_release(close); + if (!signal) return false; + if (signal->kind == SCR_DYN_UNDEF) { + scr_dyn_release(signal); + return true; + } + /* validateAbortSignal: an object with an `aborted` member. */ + bool valid = false; + if (ws_is_object(signal)) { + ScrStr *key = scr_str_new("aborted", 7); + valid = scr_dyn_in(signal, key); + scr_str_release(key); + if (scr_exc_pending()) { + scr_dyn_release(signal); + return false; + } + } + if (!valid) { + scr_dyn_arg_type_fail("options.signal", "an instance of AbortSignal", signal); + scr_dyn_release(signal); + return false; + } + *signal_out = signal; + return true; +} + +/* ── own properties ── */ + +/* An own property a program set on the state's object (+1), or NULL. */ +static ScrDyn *ws_own_get(void *obj, const char *key, size_t len) { + /* scr_dyn_handle_class_method reads methods through a handle without a + * state. */ + if (!obj) return NULL; + ScrDyn *own = ((WsObj *)obj)->own; + ScrDyn *value = own ? scr_dyn_obj_get(own, key, len) : NULL; + return value ? scr_dyn_retain(value) : NULL; +} + +/* A method call that an own property of the name overrides: true with + * *result set (+1, or NULL with the exception pending). */ +static bool ws_own_invoke(void *obj, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what, ScrDyn **result) { + ScrDyn *fn = ws_own_get(obj, method, strlen(method)); + if (!fn) return false; + if (!scr_dyn_is_callable(fn)) { + scr_dyn_release(fn); + char buf[256]; + int n = snprintf(buf, sizeof buf, "%s is not a function", what && *what ? what : method); + scr_throw_error_msg(SCR_ERR_TYPE, buf, (size_t)n); + *result = NULL; + return true; + } + *result = ws_call(fn, self, args, argc, what); + scr_dyn_release(fn); + return true; +} + +/* A write of a property on the state's object: an own property, except a + * getter-only member of the class, which strict code cannot write. */ +static bool ws_own_set(void *h, const char *key, size_t len, const ScrDyn *value) { + WsObj *o = h; + for (const ScrDynHandleAccessor *a = o->type->accessors; a && a->name; a++) { + if (strlen(a->name) != len || memcmp(a->name, key, len) != 0) continue; + char buf[256]; + int n = snprintf(buf, sizeof buf, "Cannot set property %.*s of #<%s> which has only a getter", (int)len, key, + o->type->cls); + scr_throw_error_msg(SCR_ERR_TYPE, buf, (size_t)n); + return true; + } + if (!o->own) o->own = scr_dyn_new_obj(); + scr_dyn_obj_set(o->own, key, len, scr_dyn_retain((ScrDyn *)value)); + return true; +} + /* ── the handle surfaces ───────────────────────────────────────────── */ static bool ws_name(const char *key, size_t len, const char *want) { @@ -2050,6 +4131,18 @@ static ScrDyn *ws_arg(ScrDyn *const *args, size_t argc, size_t i) { return i < argc ? args[i] : scr_dyn_undefined(); } +/* A member call on a handle (ScrDynHandleOps.invoke): an own property of + * the name first, then the class's method (_method). The class's + * method values call _method directly (ScrDynHandleClass's + * method_invoke), so a wrapper an own property installs reaches it. */ +#define WS_MEMBER_INVOKE(NAME) \ + static ScrDyn *NAME##_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, \ + const char *what) { \ + ScrDyn *own_result; \ + if (ws_own_invoke(ptr, self, method, args, argc, what, &own_result)) return own_result; \ + return NAME##_method(ptr, self, method, args, argc, what); \ + } + static ScrDyn *ws_promise_box(ScrPromise *p) { ScrDyn *box = scr_dyn_new_promise(p); scr_promise_release(p); @@ -2090,7 +4183,7 @@ static ScrDyn *ws_readable_get_reader(WsReadable *s, ScrDyn *options) { return box; } -static ScrDyn *ws_readable_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_readable_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { (void)self; (void)what; @@ -2129,17 +4222,83 @@ static ScrDyn *ws_readable_invoke(void *ptr, ScrDyn *self, const char *method, S if (empty) scr_dyn_release(empty); return iterator; } - if (ws_name(method, len, "pipeTo") || ws_name(method, len, "pipeThrough")) { - static const char message[] = "ReadableStream.prototype.pipeTo and pipeThrough are not supported yet"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); - return NULL; + if (ws_name(method, len, "pipeTo")) { + ScrDyn *destination = ws_arg(args, argc, 0); + WsWritable *dest = ws_writable_of(destination); + if (!dest) { + scr_dyn_arg_type_fail("transform.writable", "an instance of WritableStream", destination); + return ws_rejected_pending_box(); + } + ScrDyn *options = ws_arg(args, argc, 1); + ScrDyn *empty = options->kind == SCR_DYN_UNDEF ? scr_dyn_new_obj() : NULL; + bool prevent_close = false, prevent_abort = false, prevent_cancel = false; + ScrDyn *signal; + bool ok = ws_pipe_options(empty ? empty : options, &prevent_close, &prevent_abort, &prevent_cancel, &signal); + if (empty) scr_dyn_release(empty); + if (!ok) return ws_rejected_pending_box(); + ScrPromise *result; + if (ws_readable_locked(s)) result = ws_promise_state_rejected("The ReadableStream is locked"); + else if (ws_writable_locked(dest)) result = ws_promise_state_rejected("The WritableStream is locked"); + else result = ws_readable_pipe_to(s, dest, prevent_close, prevent_abort, prevent_cancel, signal); + if (signal) scr_dyn_release(signal); + return ws_promise_box(result); + } + if (ws_name(method, len, "pipeThrough")) { + ScrDyn *transform = ws_arg(args, argc, 0); + ScrDyn *readable = ws_get(transform, "readable"); + if (!readable) return NULL; + if (!ws_stream_of(readable)) { + scr_dyn_arg_type_fail("transform.readable", "an instance of ReadableStream", readable); + scr_dyn_release(readable); + return NULL; + } + ScrDyn *writable = ws_get(transform, "writable"); + if (!writable) { + scr_dyn_release(readable); + return NULL; + } + WsWritable *dest = ws_writable_of(writable); + if (!dest) { + scr_dyn_arg_type_fail("transform.writable", "an instance of WritableStream", writable); + scr_dyn_release(readable); + scr_dyn_release(writable); + return NULL; + } + ScrDyn *options = ws_arg(args, argc, 1); + ScrDyn *empty = options->kind == SCR_DYN_UNDEF ? scr_dyn_new_obj() : NULL; + bool prevent_close = false, prevent_abort = false, prevent_cancel = false; + ScrDyn *signal = NULL; + bool ok = ws_pipe_options(empty ? empty : options, &prevent_close, &prevent_abort, &prevent_cancel, &signal); + if (empty) scr_dyn_release(empty); + if (ok && ws_readable_locked(s)) { + ws_state_error("The ReadableStream is locked"); + ok = false; + } else if (ok && ws_writable_locked(dest)) { + ws_state_error("The WritableStream is locked"); + ok = false; + } + if (ok) { + ScrPromise *piped = ws_readable_pipe_to(s, dest, prevent_close, prevent_abort, prevent_cancel, signal); + ws_set_handled(piped); + scr_promise_release(piped); + } + if (signal) scr_dyn_release(signal); + scr_dyn_release(writable); + if (!ok) { + scr_dyn_release(readable); + return NULL; + } + return readable; } return ws_invoke_member(self, method, args, argc, what); } +WS_MEMBER_INVOKE(ws_readable) static ScrDyn *ws_readable_get(void *ptr, const char *key, size_t len) { WsReadable *s = ptr; if (ws_name(key, len, "locked")) return scr_dyn_new_bool(ws_readable_locked(s)); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; return scr_dyn_handle_class_member(SCR_DYNH_WS_READABLE, ptr, key, len); } @@ -2158,7 +4317,6 @@ static const ScrDynHandleMethod ws_readable_methods[] = { {NULL, 0, false, NULL}, }; -static const ScrDynHandleAccessor ws_readable_accessors[] = {{"locked", false}, {NULL, false}}; static const ScrDynHandleStatic ws_readable_statics[] = {{"from", 1, ws_readable_from_thunk}, {NULL, 0, NULL}}; @@ -2166,7 +4324,7 @@ static const char *const ws_readable_order[] = {"locked", "cancel", "getReader", "tee", "values", NULL}; static const ScrDynHandleClass ws_readable_class = { - "ReadableStream", 0, ws_readable_construct, ws_readable_accessors, ws_readable_statics, ws_readable_order, + "ReadableStream", 0, ws_readable_construct, ws_readable_accessors, ws_readable_statics, ws_readable_order, ws_readable_method, }; /* ReadableStreamDefaultReader */ @@ -2210,7 +4368,7 @@ static ScrDyn *ws_reader_read(WsReader *r) { return ws_promise_box(result); } -static ScrDyn *ws_reader_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_reader_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { (void)self; (void)what; @@ -2227,10 +4385,13 @@ static ScrDyn *ws_reader_invoke(void *ptr, ScrDyn *self, const char *method, Scr } return ws_invoke_member(self, method, args, argc, what); } +WS_MEMBER_INVOKE(ws_reader) static ScrDyn *ws_reader_get(void *ptr, const char *key, size_t len) { WsReader *r = ptr; if (ws_name(key, len, "closed")) return scr_dyn_new_promise(ws_reader_closed_promise(r)); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; return scr_dyn_handle_class_member(SCR_DYNH_WS_READER, ptr, key, len); } @@ -2238,17 +4399,16 @@ static const ScrDynHandleMethod ws_reader_methods[] = { {"read", 0, true, NULL}, {"releaseLock", 0, false, NULL}, {"cancel", 0, true, NULL}, {NULL, 0, false, NULL}, }; -static const ScrDynHandleAccessor ws_reader_accessors[] = {{"closed", true}, {NULL, false}}; static const char *const ws_reader_order[] = {"read", "releaseLock", "closed", "cancel", NULL}; static const ScrDynHandleClass ws_reader_class = { - "ReadableStreamDefaultReader", 1, ws_reader_construct, ws_reader_accessors, NULL, ws_reader_order, + "ReadableStreamDefaultReader", 1, ws_reader_construct, ws_reader_accessors, NULL, ws_reader_order, ws_reader_method, }; /* ReadableStreamDefaultController */ -static ScrDyn *ws_rcontroller_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, +static ScrDyn *ws_rcontroller_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, const char *what) { (void)self; (void)what; @@ -2276,9 +4436,12 @@ static ScrDyn *ws_rcontroller_invoke(void *ptr, ScrDyn *self, const char *method } return ws_invoke_member(self, method, args, argc, what); } +WS_MEMBER_INVOKE(ws_rcontroller) static ScrDyn *ws_rcontroller_get(void *ptr, const char *key, size_t len) { if (ws_name(key, len, "desiredSize")) return ws_rcontroller_desired_size(ptr); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; return scr_dyn_handle_class_member(SCR_DYNH_WS_RCONTROLLER, ptr, key, len); } @@ -2286,12 +4449,506 @@ static const ScrDynHandleMethod ws_rcontroller_methods[] = { {"close", 0, false, NULL}, {"enqueue", 0, false, NULL}, {"error", 0, false, NULL}, {NULL, 0, false, NULL}, }; -static const ScrDynHandleAccessor ws_rcontroller_accessors[] = {{"desiredSize", false}, {NULL, false}}; static const char *const ws_rcontroller_order[] = {"desiredSize", "close", "enqueue", "error", NULL}; static const ScrDynHandleClass ws_rcontroller_class = { - "ReadableStreamDefaultController", 0, NULL, ws_rcontroller_accessors, NULL, ws_rcontroller_order, + "ReadableStreamDefaultController", 0, NULL, ws_rcontroller_accessors, NULL, ws_rcontroller_order, ws_rcontroller_method, +}; + +/* WritableStream */ +static ScrDyn *ws_writable_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + WsWritable *s = ptr; + size_t len = strlen(method); + if (ws_name(method, len, "abort")) { + if (ws_writable_locked(s)) return ws_promise_box(ws_promise_state_rejected("WritableStream is locked")); + return ws_promise_box(ws_writable_abort(s, ws_arg(args, argc, 0))); + } + if (ws_name(method, len, "close")) { + if (ws_writable_locked(s)) return ws_promise_box(ws_promise_state_rejected("WritableStream is locked")); + if (s->close_queued_or_inflight) + return ws_promise_box(ws_promise_state_rejected("Failure closing WritableStream")); + return ws_promise_box(ws_writable_close(s)); + } + if (ws_name(method, len, "getWriter")) { + WsWriter *w = ws_writer_new(s); + if (!w) return NULL; + ScrDyn *box = ws_writer_box(w); + ws_release(w); + return box; + } + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_writable) + +static ScrDyn *ws_writable_get(void *ptr, const char *key, size_t len) { + WsWritable *s = ptr; + if (ws_name(key, len, "locked")) return scr_dyn_new_bool(ws_writable_locked(s)); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(SCR_DYNH_WS_WRITABLE, ptr, key, len); +} + +static const ScrDynHandleMethod ws_writable_methods[] = { + {"abort", 0, true, NULL}, {"close", 0, true, NULL}, {"getWriter", 0, false, NULL}, {NULL, 0, false, NULL}, +}; + + +static const char *const ws_writable_order[] = {"locked", "abort", "close", "getWriter", NULL}; + +static const ScrDynHandleClass ws_writable_class = { + "WritableStream", 0, ws_writable_construct, ws_writable_accessors, NULL, ws_writable_order, ws_writable_method, +}; + +/* WritableStreamDefaultWriter */ +static ScrDyn *ws_writer_construct(ScrDyn *const *args, size_t argc) { + ScrDyn *stream = ws_arg(args, argc, 0); + WsWritable *s = ws_writable_of(stream); + if (!s) { + scr_dyn_arg_type_fail("stream", "an instance of WritableStream", stream); + return NULL; + } + WsWriter *w = ws_writer_new(s); + if (!w) return NULL; + ScrDyn *box = ws_writer_box(w); + ws_release(w); + return box; +} + +static const char ws_writer_unbound[] = "Writer is not bound to a WritableStream"; + +static ScrDyn *ws_writer_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + WsWriter *w = ptr; + size_t len = strlen(method); + if (ws_name(method, len, "abort")) { + if (!w->stream) return ws_promise_box(ws_promise_state_rejected(ws_writer_unbound)); + return ws_promise_box(ws_writable_abort(w->stream, ws_arg(args, argc, 0))); + } + if (ws_name(method, len, "close")) { + if (!w->stream) return ws_promise_box(ws_promise_state_rejected(ws_writer_unbound)); + if (w->stream->close_queued_or_inflight) + return ws_promise_box(ws_promise_state_rejected("Failure to close WritableStream")); + return ws_promise_box(ws_writable_close(w->stream)); + } + if (ws_name(method, len, "releaseLock")) { + if (w->stream) ws_writer_release_lock(w); + return ws_undef(); + } + if (ws_name(method, len, "write")) { + if (!w->stream) return ws_promise_box(ws_promise_state_rejected(ws_writer_unbound)); + return ws_promise_box(ws_writer_write(w, ws_arg(args, argc, 0))); + } + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_writer) + +static ScrDyn *ws_writer_get(void *ptr, const char *key, size_t len) { + WsWriter *w = ptr; + if (ws_name(key, len, "closed")) return scr_dyn_new_promise(ws_writer_closed_promise(w)); + if (ws_name(key, len, "ready")) return scr_dyn_new_promise(ws_writer_ready_promise(w)); + if (ws_name(key, len, "desiredSize")) { + if (!w->stream) { + ws_state_error(ws_writer_unbound); + return NULL; + } + return ws_writer_desired_size(w); + } + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(SCR_DYNH_WS_WRITER, ptr, key, len); +} + +static const ScrDynHandleMethod ws_writer_methods[] = { + {"abort", 0, true, NULL}, {"close", 0, true, NULL}, {"releaseLock", 0, false, NULL}, + {"write", 0, true, NULL}, {NULL, 0, false, NULL}, +}; + + +static const char *const ws_writer_order[] = {"closed", "desiredSize", "ready", "abort", + "close", "releaseLock", "write", NULL}; + +static const ScrDynHandleClass ws_writer_class = { + "WritableStreamDefaultWriter", 1, ws_writer_construct, ws_writer_accessors, NULL, ws_writer_order, ws_writer_method, +}; + +/* WritableStreamDefaultController */ +static ScrDyn *ws_wcontroller_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + WsWController *c = ptr; + size_t len = strlen(method); + if (ws_name(method, len, "error")) { + if (c->stream->state == WS_W_WRITABLE) ws_wcontroller_error(c, ws_arg(args, argc, 0)); + return ws_undef(); + } + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_wcontroller) + +static ScrDyn *ws_wcontroller_get(void *ptr, const char *key, size_t len) { + if (ws_name(key, len, "signal")) return ws_wcontroller_signal(ptr); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(SCR_DYNH_WS_WCONTROLLER, ptr, key, len); +} + +static const ScrDynHandleMethod ws_wcontroller_methods[] = {{"error", 0, false, NULL}, {NULL, 0, false, NULL}}; + + +static const char *const ws_wcontroller_order[] = {"signal", "error", NULL}; + +static const ScrDynHandleClass ws_wcontroller_class = { + "WritableStreamDefaultController", 0, NULL, ws_wcontroller_accessors, NULL, ws_wcontroller_order, ws_wcontroller_method, +}; + +/* TransformStream */ +static ScrDyn *ws_transform_get(void *ptr, const char *key, size_t len) { + WsTransform *t = ptr; + if (ws_name(key, len, "readable")) return ws_stream_box(t->readable); + if (ws_name(key, len, "writable")) return ws_writable_box(t->writable); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(SCR_DYNH_WS_TRANSFORM, ptr, key, len); +} + +static ScrDyn *ws_transform_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + (void)ptr; + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_transform) + + +static const ScrDynHandleClass ws_transform_class = { + "TransformStream", 0, ws_transform_construct, ws_transform_accessors, NULL, NULL, ws_transform_method, +}; + +/* TransformStreamDefaultController */ +static ScrDyn *ws_tcontroller_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + WsTController *c = ptr; + size_t len = strlen(method); + if (ws_name(method, len, "enqueue")) { + if (!ws_tcontroller_enqueue(c, ws_arg(args, argc, 0))) return NULL; + return ws_undef(); + } + if (ws_name(method, len, "error")) { + ws_transform_error(c->stream, ws_arg(args, argc, 0)); + return ws_undef(); + } + if (ws_name(method, len, "terminate")) { + ws_tcontroller_terminate(c); + return ws_undef(); + } + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_tcontroller) + +static ScrDyn *ws_tcontroller_get(void *ptr, const char *key, size_t len) { + WsTController *c = ptr; + if (ws_name(key, len, "desiredSize")) return ws_rcontroller_desired_size(c->stream->readable->controller); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(SCR_DYNH_WS_TCONTROLLER, ptr, key, len); +} + +static const ScrDynHandleMethod ws_tcontroller_methods[] = { + {"enqueue", 0, false, NULL}, {"error", 0, false, NULL}, {"terminate", 0, false, NULL}, {NULL, 0, false, NULL}, +}; + + +static const char *const ws_tcontroller_order[] = {"desiredSize", "enqueue", "error", "terminate", NULL}; + +static const ScrDynHandleClass ws_tcontroller_class = { + "TransformStreamDefaultController", 0, NULL, ws_tcontroller_accessors, NULL, ws_tcontroller_order, ws_tcontroller_method, +}; + +/* ── TextEncoderStream and TextDecoderStream (webstreams/encoding.js) */ + +/* A TransformStream whose transformer callbacks are native functions, as + * Node's arrow-function transformers are. */ +typedef struct WsTextStream { + WsObj obj; + WsTransform *transform; /* +1 */ + bool decoder; + /* TextEncoderStream: the trailing lone high surrogate of the last chunk + * (its 3-byte WTF-8 storage), or NULL. */ + ScrStr *pending_high; + /* TextDecoderStream: the decoder's encoding id (-1 = utf-8), flags and + * streaming state. */ + double encoding; + bool fatal, ignore_bom; + ScrDyn *state; /* +1 */ +} WsTextStream; + +static void ws_text_trace(void *obj, ScrTraceVisit visit, void *ctx) { + WsTextStream *t = obj; + ws_visit(t->transform, visit, ctx); + ws_visit(t->state, visit, ctx); +} + +static void ws_text_drop(void *obj, bool gc) { + WsTextStream *t = obj; + ws_drop_obj(t->transform, gc); + if (t->pending_high) scr_str_release(t->pending_high); + ws_drop_dyn(t->state, gc); +} + +static const WsObjType ws_text_encoder_type = {ws_text_trace, ws_text_drop, "TextEncoderStream", + ws_text_encoder_accessors}; +static const WsObjType ws_text_decoder_type = {ws_text_trace, ws_text_drop, "TextDecoderStream", + ws_text_decoder_accessors}; + +static WsTextStream *ws_text_of(ScrClosure *closure) { return scr_box_get_ref(closure->caps[0]); } + +/* The controller argument of a transformer callback. */ +static WsTController *ws_text_controller(ScrDyn *const *args, size_t argc) { + ScrDyn *c = ws_arg(args, argc, 1); + if (c->kind == SCR_DYN_HANDLE && c->v.handle.tag == SCR_DYNH_WS_TCONTROLLER) return c->v.handle.ptr; + return NULL; +} + +/* String(value): ToString, with a symbol's description. */ +static ScrStr *ws_string_of(ScrDyn *value) { + if (value->kind == SCR_DYN_SYMBOL) return scr_sym_to_string(value->v.symbol.value); + return scr_dyn_string_coerce_js(value); +} + +/* The bytes as a Uint8Array chunk enqueued on the transform: false with + * the exception pending. */ +static bool ws_text_enqueue_bytes(WsTController *c, ScrBytes *bytes) { + bytes->is_buffer = false; + ScrDyn *chunk = scr_dyn_new_bytes(bytes); + bool ok = ws_tcontroller_enqueue(c, chunk); + scr_dyn_release(chunk); + return ok; +} + +static ScrDyn *ws_text_encoder_transform(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + WsTextStream *t = ws_text_of(closure); + WsTController *c = ws_text_controller(args, argc); + ScrDyn *result = NULL; + ScrStr *chunk = ws_string_of(ws_arg(args, argc, 0)); + if (!chunk) goto done; + if (chunk->len == 0) { + result = ws_undef(); + goto done; + } + if (t->pending_high) { + ScrStr *joined = scr_str_concat(t->pending_high, chunk); + scr_str_release(chunk); + scr_str_release(t->pending_high); + t->pending_high = NULL; + chunk = joined; + } + /* A lone high surrogate D800..DBFF is ED A0..AF xx. */ + size_t len = chunk->len; + if (len >= 3 && (unsigned char)chunk->data[len - 3] == 0xED && + ((unsigned char)chunk->data[len - 2] & 0xF0) == 0xA0) { + t->pending_high = scr_str_new(chunk->data + len - 3, 3); + ScrStr *rest = scr_str_new(chunk->data, len - 3); + scr_str_release(chunk); + chunk = rest; + if (chunk->len == 0) { + result = ws_undef(); + goto done; + } + } + ScrStr *utf8 = scr_str_new("utf8", 4); + ScrBytes *bytes = scr_bytes_from_str(chunk, utf8); + scr_str_release(utf8); + bool ok = c && ws_text_enqueue_bytes(c, bytes); + scr_bytes_release(bytes); + if (ok) result = ws_undef(); +done: + if (chunk) scr_str_release(chunk); + ws_release(t); + return result; +} + +static ScrDyn *ws_text_encoder_flush(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + WsTextStream *t = ws_text_of(closure); + /* flush receives the controller as its only argument. */ + ScrDyn *cv = ws_arg(args, argc, 0); + WsTController *c = cv->kind == SCR_DYN_HANDLE && cv->v.handle.tag == SCR_DYNH_WS_TCONTROLLER ? cv->v.handle.ptr : NULL; + ScrDyn *result = ws_undef(); + if (t->pending_high && c) { + ScrBytes *bytes = scr_bytes_new(SCR_BYTES_U8, 3); + bytes->data[0] = 0xEF; + bytes->data[1] = 0xBF; + bytes->data[2] = 0xBD; + if (!ws_text_enqueue_bytes(c, bytes)) { + scr_dyn_release(result); + result = NULL; + } + scr_bytes_release(bytes); + } + ws_release(t); + return result; +} + +static ScrDyn *ws_text_decoder_transform(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + WsTextStream *t = ws_text_of(closure); + WsTController *c = ws_text_controller(args, argc); + ScrDyn *result = NULL; + ScrDyn *chunk = ws_arg(args, argc, 0); + if (chunk->kind == SCR_DYN_UNDEF) { + scr_dyn_arg_type_fail("chunk", "of type string", chunk); + ws_release(t); + return NULL; + } + ScrBytes *bytes = scr_bytes_buffer_source(chunk); + if (!bytes) { + ws_release(t); + return NULL; + } + ScrStr *text = scr_text_decode_stream(t->state, bytes, t->encoding, t->fatal, t->ignore_bom, true); + scr_bytes_release(bytes); + if (text) { + bool ok = true; + if (text->len) { + ScrDyn *value = scr_dyn_new_str(text); + ok = c && ws_tcontroller_enqueue(c, value); + scr_dyn_release(value); + } + scr_str_release(text); + if (ok) result = ws_undef(); + } + ws_release(t); + return result; +} + +static ScrDyn *ws_text_decoder_flush(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + WsTextStream *t = ws_text_of(closure); + ScrDyn *cv = ws_arg(args, argc, 0); + WsTController *c = cv->kind == SCR_DYN_HANDLE && cv->v.handle.tag == SCR_DYNH_WS_TCONTROLLER ? cv->v.handle.ptr : NULL; + ScrDyn *result = NULL; + ScrBytes *empty = scr_bytes_new(SCR_BYTES_U8, 0); + ScrStr *text = scr_text_decode_stream(t->state, empty, t->encoding, t->fatal, t->ignore_bom, false); + scr_bytes_release(empty); + if (text) { + bool ok = true; + if (text->len) { + ScrDyn *value = scr_dyn_new_str(text); + ok = c && ws_tcontroller_enqueue(c, value); + scr_dyn_release(value); + } + scr_str_release(text); + if (ok && c) ws_tcontroller_terminate(c); + if (ok) result = ws_undef(); + } + ws_release(t); + return result; +} + +static ScrDyn *ws_text_callback(WsTextStream *t, ScrDynThunk thunk, uint32_t arity, const char *name) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new_obj(ws_retain, ws_release, ws_obj_trace); + scr_box_set_ref(closure->caps[0], ws_retain(t)); + return scr_dyn_new_func(closure, thunk, arity, "", name); +} + +/* The transform with the stream's native transformer: false with the + * exception pending. */ +static bool ws_text_attach(WsTextStream *t, ScrDynThunk transform, ScrDynThunk flush) { + ScrDyn *transformer = scr_dyn_new_obj(); + scr_dyn_obj_set(transformer, "transform", 9, ws_text_callback(t, transform, 2, "transform")); + scr_dyn_obj_set(transformer, "flush", 5, ws_text_callback(t, flush, 1, "flush")); + ScrDyn *box = ws_transform_construct(&transformer, 1); + scr_dyn_release(transformer); + if (!box) return false; + t->transform = ws_retain(box->v.handle.ptr); + scr_dyn_release(box); + return true; +} + +static ScrDyn *ws_text_encoder_construct(ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ws_install(); + WsTextStream *t = ws_obj_new(sizeof *t, &ws_text_encoder_type); + ScrDyn *result = NULL; + if (ws_text_attach(t, ws_text_encoder_transform, ws_text_encoder_flush)) + result = ws_box(t, SCR_DYNH_WS_TEXT_ENCODER); + ws_release(t); + return result; +} + +/* new TextDecoder(encoding, options) as TextDecoderStream makes it. */ +static ScrDyn *ws_text_decoder_construct(ScrDyn *const *args, size_t argc) { + ScrDyn *label = argc > 0 && args[0]->kind != SCR_DYN_UNDEF ? args[0] : NULL; + ScrDyn *options = argc > 1 && args[1]->kind != SCR_DYN_UNDEF ? args[1] : NULL; + double encoding = -1; + if (label) { + encoding = scr_text_decoder_encoding(label); + if (scr_exc_pending()) return NULL; + } + bool fatal = false, ignore_bom = false; + if (options) { + if (!ws_validate_object(options, "options", true)) return NULL; + ScrDyn *f = ws_get(options, "fatal"); + if (!f) return NULL; + fatal = scr_dyn_truthy(f); + scr_dyn_release(f); + ScrDyn *b = ws_get(options, "ignoreBOM"); + if (!b) return NULL; + ignore_bom = scr_dyn_truthy(b); + scr_dyn_release(b); + } + if (encoding >= 0) { + ScrStr *name = scr_text_decoder_name(encoding); + char buf[160]; + int n = snprintf(buf, sizeof buf, "TextDecoderStream for the '%.*s' encoding is not supported yet", (int)name->len, + name->data); + scr_str_release(name); + scr_throw_error_msg_code(SCR_ERR_ERROR, buf, (size_t)n, "SC2020"); + return NULL; + } + ws_install(); + WsTextStream *t = ws_obj_new(sizeof *t, &ws_text_decoder_type); + t->decoder = true; + t->encoding = encoding; + t->fatal = fatal; + t->ignore_bom = ignore_bom; + t->state = scr_dyn_new_obj(); + ScrDyn *result = NULL; + if (ws_text_attach(t, ws_text_decoder_transform, ws_text_decoder_flush)) + result = ws_box(t, SCR_DYNH_WS_TEXT_DECODER); + ws_release(t); + return result; +} + +static ScrDyn *ws_text_get(void *ptr, const char *key, size_t len) { + WsTextStream *t = ptr; + ScrDynHandleTag tag = t->decoder ? SCR_DYNH_WS_TEXT_DECODER : SCR_DYNH_WS_TEXT_ENCODER; + if (ws_name(key, len, "encoding")) { + ScrStr *name = scr_text_decoder_name(t->decoder ? t->encoding : -1); + ScrDyn *value = scr_dyn_new_str(name); + scr_str_release(name); + return value; + } + if (ws_name(key, len, "readable")) return ws_stream_box(t->transform->readable); + if (ws_name(key, len, "writable")) return ws_writable_box(t->transform->writable); + if (t->decoder && ws_name(key, len, "fatal")) return scr_dyn_new_bool(t->fatal); + if (t->decoder && ws_name(key, len, "ignoreBOM")) return scr_dyn_new_bool(t->ignore_bom); + ScrDyn *own = ws_own_get(ptr, key, len); + if (own) return own; + return scr_dyn_handle_class_member(tag, ptr, key, len); +} + +static ScrDyn *ws_text_method(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, + const char *what) { + return ws_invoke_member(self, method, args, argc, what); +} +WS_MEMBER_INVOKE(ws_text) + +static const ScrDynHandleClass ws_text_encoder_class = { + "TextEncoderStream", 0, ws_text_encoder_construct, ws_text_encoder_accessors, NULL, NULL, ws_text_method, +}; + +static const ScrDynHandleClass ws_text_decoder_class = { + "TextDecoderStream", 0, ws_text_decoder_construct, ws_text_decoder_accessors, NULL, NULL, ws_text_method, }; /* ── queuing strategies ────────────────────────────────────────────── */ @@ -2412,14 +5069,14 @@ static bool ws_set_noop(void *h, const char *key, size_t len, const ScrDyn *valu return false; } -#define WS_OPS(NAME, CLS, RETAIN, RELEASE, INVOKE, GET, METHODS, INVALID) \ +#define WS_OPS(NAME, CLS, RETAIN, RELEASE, INVOKE, GET, SET, METHODS, INVALID) \ static const ScrDynHandleOps NAME = { \ .cls = CLS, \ .retain = RETAIN, \ .release = RELEASE, \ .invoke = INVOKE, \ .get = GET, \ - .set = ws_set_noop, \ + .set = SET, \ .json_empty_object = true, \ .to_string_tag = CLS, \ .methods = METHODS, \ @@ -2433,7 +5090,7 @@ static const ScrDynHandleOps ws_readable_ops = { .release = ws_release, .invoke = ws_readable_invoke, .get = ws_readable_get, - .set = ws_set_noop, + .set = ws_own_set, .json_empty_object = true, .to_string_tag = "ReadableStream", .symbol_get = ws_readable_symbol_get, @@ -2442,16 +5099,43 @@ static const ScrDynHandleOps ws_readable_ops = { .rejected = ws_rejected_value, }; -WS_OPS(ws_reader_ops, "ReadableStreamDefaultReader", ws_retain, ws_release, ws_reader_invoke, - ws_reader_get, ws_reader_methods, +WS_OPS(ws_reader_ops, "ReadableStreamDefaultReader", ws_retain, ws_release, ws_reader_invoke, ws_reader_get, ws_own_set, ws_reader_methods, "Value of \"this\" must be of type ReadableStreamDefaultReader"); -WS_OPS(ws_rcontroller_ops, "ReadableStreamDefaultController", ws_retain, ws_release, - ws_rcontroller_invoke, ws_rcontroller_get, ws_rcontroller_methods, +WS_OPS(ws_rcontroller_ops, "ReadableStreamDefaultController", ws_retain, ws_release, ws_rcontroller_invoke, ws_rcontroller_get, ws_own_set, ws_rcontroller_methods, "Value of \"this\" must be of type ReadableStreamDefaultController"); -WS_OPS(ws_count_strategy_ops, "CountQueuingStrategy", ws_strategy_retain, ws_strategy_release, ws_strategy_invoke, - ws_strategy_get, NULL, "Value of \"this\" must be of type CountQueuingStrategy"); -WS_OPS(ws_byte_strategy_ops, "ByteLengthQueuingStrategy", ws_strategy_retain, ws_strategy_release, - ws_strategy_invoke, ws_strategy_get, NULL, +WS_OPS(ws_writable_ops, "WritableStream", ws_retain, ws_release, ws_writable_invoke, ws_writable_get, ws_own_set, + ws_writable_methods, "Value of \"this\" must be of type WritableStream"); +WS_OPS(ws_writer_ops, "WritableStreamDefaultWriter", ws_retain, ws_release, ws_writer_invoke, ws_writer_get, ws_own_set, + ws_writer_methods, "Value of \"this\" must be of type WritableStreamDefaultWriter"); +WS_OPS(ws_wcontroller_ops, "WritableStreamDefaultController", ws_retain, ws_release, ws_wcontroller_invoke, ws_wcontroller_get, ws_own_set, ws_wcontroller_methods, "Value of \"this\" must be of type WritableStreamDefaultController"); +WS_OPS(ws_transform_ops, "TransformStream", ws_retain, ws_release, ws_transform_invoke, ws_transform_get, ws_own_set, NULL, + "Value of \"this\" must be of type TransformStream"); +WS_OPS(ws_tcontroller_ops, "TransformStreamDefaultController", ws_retain, ws_release, ws_tcontroller_invoke, ws_tcontroller_get, ws_own_set, ws_tcontroller_methods, "Value of \"this\" must be of type TransformStreamDefaultController"); +/* TextEncoderStream and TextDecoderStream have no @@toStringTag. */ +static const ScrDynHandleOps ws_text_encoder_ops = { + .cls = "TextEncoderStream", + .retain = ws_retain, + .release = ws_release, + .invoke = ws_text_invoke, + .get = ws_text_get, + .set = ws_own_set, + .json_empty_object = true, + .invalid_this = "Value of \"this\" must be of type TextEncoderStream", + .rejected = ws_rejected_value, +}; +static const ScrDynHandleOps ws_text_decoder_ops = { + .cls = "TextDecoderStream", + .retain = ws_retain, + .release = ws_release, + .invoke = ws_text_invoke, + .get = ws_text_get, + .set = ws_own_set, + .json_empty_object = true, + .invalid_this = "Value of \"this\" must be of type TextDecoderStream", + .rejected = ws_rejected_value, +}; +WS_OPS(ws_count_strategy_ops, "CountQueuingStrategy", ws_strategy_retain, ws_strategy_release, ws_strategy_invoke, ws_strategy_get, ws_set_noop, NULL, "Value of \"this\" must be of type CountQueuingStrategy"); +WS_OPS(ws_byte_strategy_ops, "ByteLengthQueuingStrategy", ws_strategy_retain, ws_strategy_release, ws_strategy_invoke, ws_strategy_get, ws_set_noop, NULL, "Value of \"this\" must be of type ByteLengthQueuingStrategy"); static SCR_TL bool ws_installed; @@ -2470,18 +5154,30 @@ static void ws_install(void) { scr_dyn_handle_class_install(SCR_DYNH_WS_RCONTROLLER, &ws_rcontroller_ops, &ws_rcontroller_class); scr_dyn_handle_class_install(SCR_DYNH_WS_COUNT_STRATEGY, &ws_count_strategy_ops, &ws_count_strategy_class); scr_dyn_handle_class_install(SCR_DYNH_WS_BYTE_STRATEGY, &ws_byte_strategy_ops, &ws_byte_strategy_class); - /* The fetch unit's streams, readers and controllers (a Response body) - * are instances of the same classes. */ + scr_dyn_handle_class_install(SCR_DYNH_WS_WRITABLE, &ws_writable_ops, &ws_writable_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_WRITER, &ws_writer_ops, &ws_writer_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_WCONTROLLER, &ws_wcontroller_ops, &ws_wcontroller_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_TRANSFORM, &ws_transform_ops, &ws_transform_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_TCONTROLLER, &ws_tcontroller_ops, &ws_tcontroller_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_TEXT_ENCODER, &ws_text_encoder_ops, &ws_text_encoder_class); + scr_dyn_handle_class_install(SCR_DYNH_WS_TEXT_DECODER, &ws_text_decoder_ops, &ws_text_decoder_class); + /* The fetch unit's streams, readers, controllers, writers and identity + * transforms (a Response body) are instances of the same classes. */ scr_dyn_handle_class_alias(SCR_DYNH_WEB_STREAM, SCR_DYNH_WS_READABLE); scr_dyn_handle_class_alias(SCR_DYNH_WEB_READER, SCR_DYNH_WS_READER); scr_dyn_handle_class_alias(SCR_DYNH_WEB_CONTROLLER, SCR_DYNH_WS_RCONTROLLER); + scr_dyn_handle_class_alias(SCR_DYNH_WEB_WRITABLE, SCR_DYNH_WS_WRITABLE); + scr_dyn_handle_class_alias(SCR_DYNH_WEB_WRITER, SCR_DYNH_WS_WRITER); + scr_dyn_handle_class_alias(SCR_DYNH_WEB_TRANSFORM, SCR_DYNH_WS_TRANSFORM); } /* The classes in the frontend's WEB_STREAM_CLASSES order. */ static ScrDynHandleTag ws_class_tag(double kind) { static const ScrDynHandleTag tags[] = { - SCR_DYNH_WS_READABLE, SCR_DYNH_WS_READER, SCR_DYNH_WS_RCONTROLLER, SCR_DYNH_WS_COUNT_STRATEGY, - SCR_DYNH_WS_BYTE_STRATEGY, + SCR_DYNH_WS_READABLE, SCR_DYNH_WS_READER, SCR_DYNH_WS_RCONTROLLER, + SCR_DYNH_WS_COUNT_STRATEGY, SCR_DYNH_WS_BYTE_STRATEGY, SCR_DYNH_WS_WRITABLE, + SCR_DYNH_WS_WRITER, SCR_DYNH_WS_WCONTROLLER, SCR_DYNH_WS_TRANSFORM, + SCR_DYNH_WS_TCONTROLLER, SCR_DYNH_WS_TEXT_ENCODER, SCR_DYNH_WS_TEXT_DECODER, }; int index = (int)kind; if (index < 0 || index >= (int)(sizeof tags / sizeof tags[0])) scr_trap("scriptc: unknown web stream class\n"); diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 697f43812..8d612db24 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -502,6 +502,86 @@ export function compareCounts(baseline, candidate) { }; } +/* ── runtime packs ─────────────────────────────────────────────────────── */ + +/** Every file a runtime-pack.json lists (each flavor's runtime unit + * variants, their bitcode, and the vendor archives), as path → sha256. */ +export function runtimePackFiles(manifest) { + const files = new Map(); + const visit = (value) => { + if (Array.isArray(value)) value.forEach(visit); + else if (value !== null && typeof value === "object") { + if (typeof value.path === "string" && typeof value.sha256 === "string") + files.set(value.path, value.sha256); + Object.values(value).forEach(visit); + } + }; + visit(manifest.flavors ?? {}); + visit(manifest.archives ?? {}); + return files; +} + +/** The runtime pack files that differ between two runtime-pack.json + * manifests: changed, added and removed paths, each sorted. */ +export function runtimePackDiff(baseline, candidate) { + const before = runtimePackFiles(baseline); + const after = runtimePackFiles(candidate); + const changed = [...before].filter(([path, sha]) => after.has(path) && after.get(path) !== sha); + return { + changed: changed.map(([path]) => path).sort(), + added: [...after.keys()].filter((path) => !before.has(path)).sort(), + removed: [...before.keys()].filter((path) => !after.has(path)).sort(), + }; +} + +/** The changed runtime pack files whose symbols executables link: runtime + * objects and vendor archives. */ +export function linkedRuntimeFiles(diff) { + return [...diff.changed, ...diff.added, ...diff.removed].filter( + (path) => path.endsWith(".o") || path.endsWith(".a"), + ); +} + +/** Defined functions of textual LLVM IR (llvm-dis output) as name → hash of + * the body. Attribute group and metadata numbers are dropped first, since + * an unrelated change elsewhere in the unit renumbers them. */ +export function llvmFunctionHashes(text) { + const functions = new Map(); + const lines = text.split("\n"); + for (let index = 0; index < lines.length; index++) { + const head = /^define\b[^@]*@("(?:[^"\\]|\\.)*"|[\w.$-]+)\(/.exec(lines[index]); + if (head === null) continue; + const body = []; + for (; index < lines.length && lines[index] !== "}"; index++) body.push(lines[index]); + const normalized = body.join("\n").replace(/ #\d+/g, "").replace(/!\d+/g, "!"); + functions.set(head[1].replace(/^"|"$/g, ""), hash(normalized)); + } + return functions; +} + +/** Names whose hashes differ between two function maps, and the names only + * one side defines. */ +export function changedFunctions(before, after) { + return { + changed: [...before] + .filter(([name, h]) => after.has(name) && after.get(name) !== h) + .map(([name]) => name) + .sort(), + added: [...after.keys()].filter((name) => !before.has(name)).sort(), + removed: [...before.keys()].filter((name) => !after.has(name)).sort(), + }; +} + +/** Whether every symbol an executable comparison added, removed or resized + * is one of `runtimeSymbols`, the symbols the changed runtime objects + * define. Mach-O nm prints no sizes, so there only additions and removals + * are checked. */ +export function confinedToRuntime(exe, runtimeSymbols) { + return [...exe.added, ...exe.removed, ...exe.resized.map((s) => s.name)].every((name) => + runtimeSymbols.has(name), + ); +} + /** The report lines the gate prints for one program. */ export function programLines(entry) { const lines = []; @@ -510,6 +590,8 @@ export function programLines(entry) { identical: "identical", allowed: "allowed ", differs: "DIFFERS ", + "runtime-only": "runtime ", + nondeterministic: "nondet ", "build-error": "ERROR ", "both-fail": "both-fail", }[entry.verdict]; @@ -536,6 +618,17 @@ export function programLines(entry) { ); } else if (entry.cachegrind?.skipped) notes.push(`cachegrind skipped: ${entry.cachegrind.skipped}`); + if (entry.runtime) { + const linked = [...entry.runtime.changed, ...entry.runtime.added]; + notes.push( + `runtime-only: links ${linked.length} changed runtime function${linked.length === 1 ? "" : "s"}` + + (linked.length + ? ` (${linked.slice(0, 8).join(", ")}${linked.length > 8 ? ", ..." : ""})` + : ""), + ); + } + if (entry.nondeterministic) + notes.push(`the baseline's own rebuild differs in ${entry.nondeterministic.join(", ")}`); for (const failure of entry.dynamicTier?.failures ?? []) notes.push(`SYMBOLS: ${failure}`); lines.push(`${label} ${entry.id}${notes.length ? ` ${notes.join("; ")}` : ""}`); // A build error's first line is often a warning; print all of it. @@ -560,7 +653,23 @@ export function programLines(entry) { /** `required` programs must build with both checkouts: a failure both share * means the gate compared nothing for them. Other programs may fail the same * way on both sides, as a corpus program the compiler refuses does. */ -export function programVerdict({ baseline, candidate, artifacts, allowed, required = false }) { +/** A difference in the release executable alone, with identical IR and + * LLVM, is "runtime-only" when `runtimeOnly` says the changed runtime + * objects explain it (the gate checks the runtime packs and the symbols). + * A difference the baseline also shows when it builds the program again is + * "nondeterministic": `nondeterministic` lists the artifact kinds whose + * baseline rebuild differs, and every differing artifact must be among + * them. Neither fails the gate unless `strict` is set. */ +export function programVerdict({ + baseline, + candidate, + artifacts, + allowed, + required = false, + runtimeOnly = false, + nondeterministic = [], + strict = false, +}) { if (!baseline.ok || !candidate.ok) { if (!baseline.ok && !candidate.ok && baseline.error === candidate.error) return { verdict: "both-fail", fails: required }; @@ -568,7 +677,12 @@ export function programVerdict({ baseline, candidate, artifacts, allowed, requir } const differing = Object.entries(artifacts).filter(([, r]) => !r.identical); if (differing.length === 0) return { verdict: "identical", fails: false }; - return allowed ? { verdict: "allowed", fails: false } : { verdict: "differs", fails: true }; + if (allowed) return { verdict: "allowed", fails: false }; + if (runtimeOnly && differing.every(([kind]) => kind === "exe")) + return { verdict: "runtime-only", fails: strict }; + if (differing.every(([kind]) => nondeterministic.includes(kind))) + return { verdict: "nondeterministic", fails: strict }; + return { verdict: "differs", fails: true }; } export function median(values) { @@ -737,7 +851,8 @@ export function mergeReports(reports) { } const counts = {}; for (const p of merged.programs) counts[p.verdict] = (counts[p.verdict] ?? 0) + 1; - if (!merged.programs.some((p) => ["identical", "allowed", "differs"].includes(p.verdict))) + const compared = ["identical", "allowed", "runtime-only", "nondeterministic", "differs"]; + if (!merged.programs.some((p) => compared.includes(p.verdict))) merged.globalFailures.push("no program built with both checkouts, so nothing was compared"); merged.failures.push(...merged.globalFailures); merged.counts = counts; diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index 75724bd7d..5b5f51a21 100644 --- a/scripts/zero-cost-gate.mjs +++ b/scripts/zero-cost-gate.mjs @@ -10,7 +10,11 @@ * The good-code set: the runtime benchmark workloads (and benchmarks/builds * applications), the self-hosted compiler (IR and LLVM), and the TypeScript * corpus programs. A program whose sources match --allow (the change's - * pattern signature) may differ; any other difference fails the gate. */ + * pattern signature) may differ. A program whose IR and LLVM are identical + * and whose executable differs only through changed runtime objects is + * "runtime-only", and a difference the baseline reproduces against itself is + * "nondeterministic": both pass with a note unless --strict. Any other + * difference fails the gate. */ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; @@ -33,19 +37,25 @@ import { artifactHash, cachedComparison, cachegrindCounts, + changedFunctions, compareArtifact, compareCounts, compareExecutables, + confinedToRuntime, DYNAMIC_WORKLOADS, definedFunctionNames, dynamicTierSymbols, + linkedRuntimeFiles, + llvmFunctionHashes, median, nodeIslandSymbols, nmOutput, + parseNm, programInputsHash, programLines, programVerdict, recordedBaseline, + runtimePackDiff, timingGroups, timingVerdict, } from "./zero-cost-compare.mjs"; @@ -62,6 +72,7 @@ const { values } = parseArgs({ shard: { type: "string" }, only: { type: "string" }, allow: { type: "string" }, + strict: { type: "boolean", default: false }, "timing-runs": { type: "string" }, "self-timing-runs": { type: "string", default: "0" }, "max-time-growth": { type: "string", default: "2" }, @@ -79,7 +90,7 @@ if (values.help || values.baseline === undefined) { console.log(`Usage: node scripts/zero-cost-gate.mjs --baseline= [--candidate=] [--programs=baseline|candidate|] [--set=bench,self,corpus] [--quick] [--corpus=ts|all] [--shard=i/n] [--only=] [--allow=] - [--timing-runs=N] [--self-timing-runs=N] [--max-time-growth=2] + [--strict] [--timing-runs=N] [--self-timing-runs=N] [--max-time-growth=2] [--max-size-growth=0] [--cachegrind] [--jobs=N] [--json=] [--keep] [--baseline-cache=] [--record-baseline-cache=] @@ -98,8 +109,20 @@ in shard 1. scripts/zero-cost-merge.mjs merges the shards' --json reports into one verdict. --only filters program ids. --allow= is the change's pattern signature: programs whose source -files match it may differ (reported as "allowed"). Any other difference in -IR, LLVM or the release executable fails the gate. +files match it may differ (reported as "allowed"). + +Other differences are classified: + runtime-only IR and LLVM identical; the executable differs only + through runtime objects that differ between the two + checkouts' runtime packs (every symbol the executable + gains, loses or resizes is defined by one of them), with + the same LLVM helper. Reported with the changed runtime + functions it links and its size change. + nondeterministic the baseline, building the program a second time, + differs from its own first build in the same artifacts. + differs anything else, in IR, LLVM or the executable. +"differs" fails the gate. runtime-only and nondeterministic pass with a +note; --strict fails them too, so any difference fails as before. --timing-runs=N (default 5) times N paired --emit=llvm builds per bench program, baseline and candidate side by side. The self-hosted compiler's @@ -290,10 +313,9 @@ function compile(tree, args) { * keeping the LLVM and IR beside the release executable. The self-hosted * compiler emits LLVM only: each of its frontend runs takes minutes, and its * LLVM names the first differing function as well as its IR would. */ -async function buildProgram(program, label) { +async function buildProgram(program, label, tree = trees[label]) { const dir = join(work, label, program.id.replace(/[^\w.-]+/g, "_")); mkdirSync(dir, { recursive: true }); - const tree = trees[label]; const failure = (result) => ({ ok: false, error: (result.stderr || result.stdout || `exit ${result.status ?? result.signal}`) @@ -434,6 +456,111 @@ async function instructionCounts(binary, args, out) { return cachegrindCounts(readFileSync(out, "utf8"), new Set(definedFunctions(binary))); } +/* ── runtime changes ───────────────────────────────────────────────────── */ + +const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); + +/** llvm-dis from PATH or the pinned LLVM install, or null. */ +function llvmDis() { + for (const candidate of ["llvm-dis", "llvm-dis-22", "/usr/lib/llvm-22/bin/llvm-dis"]) + if (spawnSync(candidate, ["--version"]).status === 0) return candidate; + const config = spawnSync("llvm-config", ["--bindir"], { encoding: "utf8" }); + const fromConfig = config.status === 0 ? join(config.stdout.trim(), "llvm-dis") : null; + return fromConfig !== null && existsSync(fromConfig) ? fromConfig : null; +} + +/** What differs between the two checkouts' runtime packs for this host, or + * null when no object or archive does: the changed pack files, whether the + * LLVM helper is the same, the symbols the changed objects define (on either + * side), and the functions that changed in the bitcode of the changed + * release and speed objects. Bitcode alone is not a change: it records the + * checkout's source path, so it differs between any two checkouts. */ +function runtimeChanges() { + const pack = (root) => + readdirSync(join(root, "packages")) + .filter((name) => name.startsWith("runtime-")) + .filter((name) => existsSync(join(root, "packages", name, "runtime-pack.json"))); + const shared = pack(trees.candidate).filter((name) => pack(trees.baseline).includes(name)); + if (shared.length !== 1) return null; + const packDir = (root) => join(root, "packages", shared[0]); + const manifest = (root) => + JSON.parse(readFileSync(join(packDir(root), "runtime-pack.json"), "utf8")); + const linked = (paths) => paths.filter((path) => !path.endsWith(".bc")); + const all = runtimePackDiff(manifest(trees.baseline), manifest(trees.candidate)); + const diff = { + changed: linked(all.changed), + added: linked(all.added), + removed: linked(all.removed), + }; + if (diff.changed.length + diff.added.length + diff.removed.length === 0) return null; + const helper = (root) => { + const file = join( + root, + "packages", + shared[0].replace(/^runtime-/, "llvm-"), + "bin/scriptc-llvm-codegen", + ); + return existsSync(file) ? sha256(file) : null; + }; + const helperSame = + helper(trees.baseline) !== null && helper(trees.baseline) === helper(trees.candidate); + const symbols = new Set(); + const objects = [...diff.changed, ...diff.added, ...diff.removed].filter((path) => + path.endsWith(".o"), + ); + for (const path of linkedRuntimeFiles(diff)) + for (const root of [trees.baseline, trees.candidate]) { + const file = join(packDir(root), path); + if (!existsSync(file)) continue; + const listed = spawnSync("nm", ["--defined-only", file], { + encoding: "utf8", + maxBuffer: 1 << 28, + }); + for (const symbol of parseNm(listed.stdout ?? "")) symbols.add(symbol.name); + } + // Changed functions, by unit, from the bitcode of the release and speed + // flavors (the ones the gate builds with). + const dis = llvmDis(); + const functions = { changed: new Set(), added: new Set(), removed: new Set(), units: new Set() }; + for (const object of objects) + functions.units.add(object.replace(/^.*\//, "").replace(/\.o$/, ".c")); + const bitcode = objects + .filter((path) => /^artifacts\/(release|speed)\//.test(path)) + .map((path) => path.replace(/\.o$/, ".bc")); + for (const path of bitcode) { + if (dis === null) continue; + const text = (root) => { + const file = join(packDir(root), path); + if (!existsSync(file)) return new Map(); + const out = spawnSync(dis, [file, "-o", "-"], { encoding: "utf8", maxBuffer: 1 << 30 }); + return llvmFunctionHashes(out.stdout ?? ""); + }; + const delta = changedFunctions(text(trees.baseline), text(trees.candidate)); + for (const kind of ["changed", "added", "removed"]) + for (const name of delta[kind]) functions[kind].add(name); + } + return { + pack: shared[0], + diff, + helperSame, + symbols, + disassembler: dis, + functions: Object.fromEntries( + Object.entries(functions).map(([kind, names]) => [kind, [...names].sort()]), + ), + }; +} + +/** The changed runtime functions an executable defines. */ +function linkedRuntimeChanges(runtime, binary) { + const defined = new Set(definedFunctions(binary)); + const pick = (names) => names.filter((name) => defined.has(name)); + return { + changed: pick(runtime.functions.changed), + added: pick(runtime.functions.added), + }; +} + /* ── main ──────────────────────────────────────────────────────────────── */ function revision(root) { @@ -523,6 +650,7 @@ if (recordPath === undefined && values["baseline-cache"] !== undefined) { const programs = goodCodeSet().filter((p) => recordPath === undefined || p.kind !== "source"); assert.ok(programs.length > 0, "the selected good-code set is empty"); +const runtime = recordPath === undefined ? runtimeChanges() : null; const report = { schema: 1, date: new Date().toISOString(), @@ -532,6 +660,16 @@ const report = { ), programsRoot, allow: values.allow ?? null, + strict: values.strict, + runtime: + runtime === null + ? null + : { + pack: runtime.pack, + ...runtime.diff, + helperSame: runtime.helperSame, + functions: runtime.functions, + }, programs: [], timing: null, failures: [], @@ -547,6 +685,24 @@ console.log( `zero-cost gate: ${programs.length} programs (${[...sets].join(", ")}), ` + `baseline ${trees.baseline}, candidate ${trees.candidate}, programs from ${programsRoot}`, ); +if (runtime !== null) { + const { changed, added, removed } = runtime.diff; + console.log( + `runtime pack ${runtime.pack} differs: ${changed.length} changed, ${added.length} added, ` + + `${removed.length} removed files; units ${runtime.functions.units.join(", ") || "none"}` + + (runtime.helperSame ? "" : "; the LLVM helper differs too, so nothing is runtime-only"), + ); + if (runtime.disassembler === null) + console.log("runtime functions: llvm-dis not found, so changed functions are not listed"); + else + console.log( + `runtime functions: ${runtime.functions.changed.length} changed` + + (runtime.functions.changed.length + ? ` (${runtime.functions.changed.slice(0, 20).join(", ")})` + : "") + + `, ${runtime.functions.added.length} added, ${runtime.functions.removed.length} removed`, + ); +} let failed = false; const started = Date.now(); @@ -641,13 +797,39 @@ await pool( (baseline.ok && candidate.ok ? compareBuilds(baseline, candidate) : {}); // The benchmark workloads and the self-hosted compiler always build. const required = !program.id.startsWith("corpus/"); - const { verdict, fails } = programVerdict({ - baseline, - candidate, - artifacts, - allowed, - required, - }); + const runtimeOnly = + runtime !== null && + runtime.helperSame && + artifacts.exe !== undefined && + !artifacts.exe.identical && + confinedToRuntime(artifacts.exe, runtime.symbols); + const judge = (nondeterministic) => + programVerdict({ + baseline, + candidate, + artifacts, + allowed, + required, + runtimeOnly, + nondeterministic, + strict: values.strict, + }); + let { verdict, fails } = judge([]); + // Build a difference the change cannot explain once more with the + // baseline: when the baseline's two builds differ in the same + // artifacts, the difference is the build's, not the change's. + let recheck = null; + if (verdict === "differs") { + const again = await buildProgram(program, "recheck", trees.baseline); + if (again.ok) { + const self = compareBuilds(baseline, again); + recheck = Object.keys(artifacts).filter( + (kind) => !artifacts[kind].identical && !self[kind]?.identical, + ); + if (again.dir && !values.keep) rmSync(again.dir, { recursive: true, force: true }); + } + if (recheck?.length) ({ verdict, fails } = judge(recheck)); + } const entry = { id: program.id, verdict, @@ -655,6 +837,8 @@ await pool( buildMs: { baseline: baseline.ms ?? null, candidate: candidate.ms ?? null }, artifacts, }; + if (verdict === "runtime-only") entry.runtime = linkedRuntimeChanges(runtime, candidate.exe); + if (verdict === "nondeterministic") entry.nondeterministic = recheck; if (settled !== null) entry.cachedBaseline = true; if (!baseline.ok) entry.baselineError = baseline.error; if (!candidate.ok) entry.candidateError = candidate.error; @@ -787,7 +971,8 @@ if (checkCandidateSelf) { const counts = {}; for (const p of report.programs) counts[p.verdict] = (counts[p.verdict] ?? 0) + 1; -if (!report.programs.some((p) => ["identical", "allowed", "differs"].includes(p.verdict))) { +const compared = ["identical", "allowed", "runtime-only", "nondeterministic", "differs"]; +if (!report.programs.some((p) => compared.includes(p.verdict))) { failed = true; const failure = "no program built with both checkouts, so nothing was compared"; report.failures.push(failure); @@ -802,11 +987,38 @@ if (values.json) { } if (!values.keep) rmSync(work, { recursive: true, force: true }); else console.log(`artifacts kept in ${work}`); +const noted = report.programs.filter((p) => + ["runtime-only", "nondeterministic"].includes(p.verdict), +); console.log( - `zero-cost gate: ${failed ? "FAIL" : "PASS"} — ${Object.entries(counts) + `zero-cost gate: ${failed ? "FAIL" : noted.length ? "PASS with notes" : "PASS"} — ${Object.entries( + counts, + ) .map(([k, v]) => `${v} ${k}`) .join(", ")} in ${fmtMs(report.elapsedMs)}` + (report.baselineCache ? ` (${report.baselineCache.used} baselines from the cache)` : ""), ); +const runtimeOnlyPrograms = report.programs.filter((p) => p.verdict === "runtime-only"); +if (runtimeOnlyPrograms.length > 0) { + const deltas = runtimeOnlyPrograms.map( + (p) => p.artifacts.exe.bytes.candidate - p.artifacts.exe.bytes.baseline, + ); + const signed = (n) => `${n > 0 ? "+" : ""}${n}`; + const linked = new Set( + runtimeOnlyPrograms.flatMap((p) => [...p.runtime.changed, ...p.runtime.added]), + ); + console.log( + ` note: ${runtimeOnlyPrograms.length} runtime-only programs (IR and LLVM identical); ` + + `executable size ${signed(Math.min(...deltas))} to ${signed(Math.max(...deltas))} bytes; ` + + `changed runtime functions linked: ${[...linked].sort().join(", ") || "none listed"}` + + (values.strict ? "" : " (--strict fails these)"), + ); +} +const nondeterministicPrograms = report.programs.filter((p) => p.verdict === "nondeterministic"); +if (nondeterministicPrograms.length > 0) + console.log( + ` note: ${nondeterministicPrograms.length} nondeterministic programs, whose baseline rebuild differs too: ` + + nondeterministicPrograms.map((p) => p.id).join(", "), + ); for (const failure of report.failures) console.log(` ${failure}`); process.exit(failed ? 1 : 0); diff --git a/tests/corpus/6804-node-island-signals/main.mjs b/tests/corpus/6804-node-island-signals/main.mjs index aaed001e7..c6d91ad7b 100644 --- a/tests/corpus/6804-node-island-signals/main.mjs +++ b/tests/corpus/6804-node-island-signals/main.mjs @@ -48,6 +48,16 @@ async function main() { process.removeListener("SIGUSR2", native); process.removeListener("SIGUSR2", second); process.removeListener("SIGHUP", hup); + + console.log("-- one function registered once and on"); + island("listenOnce", "SIGUSR2", "dup"); + island("listen", "SIGUSR2", "dup"); + process.kill(process.pid, "SIGUSR2"); + await later(50); + console.log("-- the once registration is gone"); + process.kill(process.pid, "SIGUSR2"); + await later(50); + island("unlisten", "SIGUSR2", "dup"); console.log("done"); } diff --git a/tests/corpus/6890-node-island-timer-refresh/echo.cjs b/tests/corpus/6890-node-island-timer-refresh/echo.cjs new file mode 100644 index 000000000..76d5cde1d --- /dev/null +++ b/tests/corpus/6890-node-island-timer-refresh/echo.cjs @@ -0,0 +1,3 @@ +exports.echo = function (n) { + return n; +}; diff --git a/tests/corpus/6890-node-island-timer-refresh/main.ts b/tests/corpus/6890-node-island-timer-refresh/main.ts new file mode 100644 index 000000000..3f5b68ee5 --- /dev/null +++ b/tests/corpus/6890-node-island-timer-refresh/main.ts @@ -0,0 +1,15 @@ +// @requires-libnode +// refresh() of a timer armed before Node loads restarts it with the delay +// it was created with, not the remaining delay it had at handoff: a due +// timer it refreshes does not run before Node's loop starts. The timer +// armed after the refresh fires first. +import { callSync } from "@scriptc/node"; + +const slow = setTimeout(() => console.log("slow"), 1000); +// Most of the timer's delay passes before Node loads, so its runtime entry +// reaches the hosted loop with little time left (or already due). +const start = Date.now(); +while (Date.now() - start < 800) {} +console.log("loaded", callSync("./echo.cjs", "echo", 1)); +slow.refresh(); +setTimeout(() => console.log("fast"), 400); diff --git a/tests/corpus/8363-record-inherited-keys.ts b/tests/corpus/8363-record-inherited-keys.ts new file mode 100644 index 000000000..7f9fab6e6 --- /dev/null +++ b/tests/corpus/8363-record-inherited-keys.ts @@ -0,0 +1,51 @@ +// Object.prototype's own-key tests on records of functions convert their +// argument to a property key as Node does: an array joins its elements, and +// a record or class instance with its own toString calls it. A keyed read +// that falls back to an inherited method keeps the bound method alive +// through the checked projection that lends it. +type ListTest = (key: string[]) => unknown; +const lists: Record = { a: () => "a", "a,b": () => "a,b" }; +console.log( + lists["hasOwnProperty"](["a"]), + lists["hasOwnProperty"](["a", "b"]), + lists["propertyIsEnumerable"](["b"]), +); + +type Named = { toString(): string }; +type NamedTest = (key: Named) => unknown; +const named: Record = { echo: () => "echo" }; +let calls = 0; +const echoKey: Named = { + toString() { + calls++; + return "echo"; + }, +}; +console.log( + named["hasOwnProperty"](echoKey), + named["propertyIsEnumerable"]({ toString: () => "nope" }), + calls, +); + +class Label { + readonly text: string; + constructor(text: string) { + this.text = text; + } + toString(): string { + return this.text; + } +} +type LabelTest = (key: Label) => unknown; +const labels: Record = { x: () => "x" }; +console.log(labels["hasOwnProperty"](new Label("x")), labels["hasOwnProperty"](new Label("y"))); + +type Op = (x: string) => unknown; +const table: Record = { echo: (x) => x }; +// `table[name]!` is a checked projection: its successful arm lends the +// read, whose miss binds the inherited method to the record. +function run(name: string): unknown { + return table[name]!("echo"); +} +for (const name of ["echo", "hasOwnProperty", "toString", "propertyIsEnumerable"]) + console.log(name, String(run(name))); diff --git a/tests/corpus/8364-record-nullish-receiver-arm/main.ts b/tests/corpus/8364-record-nullish-receiver-arm/main.ts new file mode 100644 index 000000000..00ff7596a --- /dev/null +++ b/tests/corpus/8364-record-nullish-receiver-arm/main.ts @@ -0,0 +1,29 @@ +// A conditional receiver whose arm reads a record with a fallback for a +// missing key, `(c ? names[k] ?? fallback(k) : other)`: the fallback's +// fresh string lives until the read of its length. +const names: Record = { a: "alpha" }; +let made = 0; +function fallback(k: string): string | undefined { + made++; + return k === "none" ? undefined : `fallback for ${k} #${made}`.repeat(2); +} +function asserted(useNames: boolean, k: string, other: string): number { + return (useNames ? (names[k] ?? fallback(k)) : other)!.length; +} +function optional(useNames: boolean, k: string, other: string): number | undefined { + return (useNames ? (names[k] ?? fallback(k)) : other)?.length; +} +function upper(useNames: boolean, k: string, other: string): string | undefined { + return (useNames ? (names[k] ?? fallback(k)) : other)?.toUpperCase(); +} +for (const k of ["a", "b", "none"]) + for (const useNames of [true, false]) { + let length: number | string; + try { + length = asserted(useNames, k, "other"); + } catch (e) { + length = (e as Error).name; + } + console.log(k, useNames, length, optional(useNames, k, "other"), upper(useNames, k, "x")); + } +console.log("made", made); diff --git a/tests/corpus/8364-record-nullish-receiver-arm/tsconfig.json b/tests/corpus/8364-record-nullish-receiver-arm/tsconfig.json new file mode 100644 index 000000000..f2e1c10cd --- /dev/null +++ b/tests/corpus/8364-record-nullish-receiver-arm/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/tests/corpus/8408-fetch-connection-reuse.ts b/tests/corpus/8408-fetch-connection-reuse.ts new file mode 100644 index 000000000..f36946629 --- /dev/null +++ b/tests/corpus/8408-fetch-connection-reuse.ts @@ -0,0 +1,95 @@ +// fetch() reuses keep-alive connections as Node's fetch does: sequential +// requests share connections, and every response is the same with reuse: +// concurrent requests, `connection: close` responses and requests, +// redirects, request bodies, an aborted response followed by another +// request, and a request after the server closed its idle connections. The +// program exits while idle connections remain open. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +let connections = 0; +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = req.url ?? "/"; + if (url === "/close") { + res.writeHead(200, { connection: "close" }); + res.end("closing"); + return; + } + if (url === "/redirect") { + res.writeHead(302, { location: "/hello" }); + res.end(); + return; + } + if (url === "/echo") { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk: string) => { + body += chunk; + }); + req.on("end", () => res.end(`echo:${body}`)); + return; + } + if (url === "/slow") { + res.writeHead(200, { "content-type": "text/plain" }); + res.write("first "); + setTimeout(() => res.end("rest"), 200); + return; + } + res.end(`hello ${url}`); +}); +server.on("connection", () => { + connections++; +}); + +async function text(port: number, path: string, init?: RequestInit): Promise { + const res = await fetch(`http://127.0.0.1:${port}${path}`, init); + return `${res.status} ${await res.text()}`; +} + +async function main(port: number): Promise { + const seen: string[] = []; + for (let i = 0; i < 5; i++) seen.push(await text(port, `/seq${i}`)); + console.log("sequential", connections < 5, seen.join(" | ")); + + const parallel = await Promise.all([1, 2, 3].map((i) => text(port, `/par${i}`))); + for (let i = 0; i < 3; i++) parallel.push(await text(port, `/after${i}`)); + console.log("concurrent", parallel.join(" | ")); + + console.log(await text(port, "/close"), await text(port, "/close")); + console.log(await text(port, "/a", { headers: { connection: "close" } }), await text(port, "/b")); + + const res = await fetch(`http://127.0.0.1:${port}/redirect`); + console.log("redirect", res.redirected, res.url.endsWith("/hello"), await res.text()); + + const bodies = []; + for (let i = 0; i < 3; i++) bodies.push(await text(port, "/echo", { method: "POST", body: `n${i}` })); + console.log("post", bodies.join(" | ")); + + const controller = new AbortController(); + const slow = await fetch(`http://127.0.0.1:${port}/slow`, { signal: controller.signal }); + controller.abort(); + try { + await slow.text(); + } catch (e) { + console.log("aborted", (e as Error).name); + } + console.log(await text(port, "/after-abort")); + + // A server that closes idle connections after 50 ms: the next request + // after a pause dials again. + const brief = createServer((req: IncomingMessage, res: ServerResponse) => res.end(`brief ${req.url}`)); + brief.keepAliveTimeout = 50; + await new Promise((resolve) => brief.listen(0, "127.0.0.1", () => resolve())); + const briefPort = (brief.address() as AddressInfo).port; + console.log(await text(briefPort, "/1"), await text(briefPort, "/2")); + await new Promise((resolve) => setTimeout(resolve, 200)); + console.log(await text(briefPort, "/3")); + + brief.close(); + server.close(); + console.log("done"); +} + +server.listen(0, "127.0.0.1", () => { + void main((server.address() as AddressInfo).port); +}); diff --git a/tests/corpus/8409-fetch-compressed-read-after-end.ts b/tests/corpus/8409-fetch-compressed-read-after-end.ts new file mode 100644 index 000000000..182c9405a --- /dev/null +++ b/tests/corpus/8409-fetch-compressed-read-after-end.ts @@ -0,0 +1,66 @@ +// A compressed fetch() response whose whole body arrived before it was read: +// the decoded body is larger than one read, so decoded output is still +// buffered when the response ends, and every later read drains it. Reads +// after a pause (the end was processed meanwhile) return the rest of the +// body, as in Node, for gzip and deflate, through a reader and through +// text(), and the next request on the same connection works. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { deflateSync, gzipSync } from "node:zlib"; + +const body = Array.from({ length: 20000 }, (_, i) => `line ${i % 97} of the body\n`).join(""); +const gzipped = gzipSync(body); +const deflated = deflateSync(body); + +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = req.url ?? "/"; + if (url === "/gzip" || url === "/gzip-text") { + res.writeHead(200, { "content-encoding": "gzip", "content-length": gzipped.length }); + res.end(gzipped); + return; + } + if (url === "/deflate") { + res.writeHead(200, { "content-encoding": "deflate", "content-length": deflated.length }); + res.end(deflated); + return; + } + res.end(`plain ${url}`); +}); + +const pause = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function readSlowly(port: number, path: string): Promise { + const res = await fetch(`http://127.0.0.1:${port}${path}`); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let reads = 0; + for (;;) { + // Let the response end before most reads. + await pause(reads < 3 ? 30 : 0); + const { done, value } = await reader.read(); + if (done) break; + reads++; + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return `${path} ${res.status} ${text.length} ${text === body}`; +} + +async function main(port: number): Promise { + console.log(await readSlowly(port, "/gzip")); + console.log(await readSlowly(port, "/deflate")); + const res = await fetch(`http://127.0.0.1:${port}/gzip-text`); + await pause(50); + const text = await res.text(); + console.log("text after end", text.length, text === body); + const plain = await fetch(`http://127.0.0.1:${port}/after`); + console.log(await plain.text()); +} + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + main(port) + .catch((error: unknown) => console.log("failed", String(error))) + .finally(() => server.close()); +}); diff --git a/tests/corpus/counted-loop-integer-limits.ts b/tests/corpus/counted-loop-integer-limits.ts new file mode 100644 index 000000000..ff65f391d --- /dev/null +++ b/tests/corpus/counted-loop-integer-limits.ts @@ -0,0 +1,152 @@ +// Counted for loops whose limit is an exact integer: an int32 local, an +// int32 field, the result of a function returning an int32, a value in a +// proven range (a masked byte, an array length), counting up and down, +// with exclusive and inclusive bounds, empty ranges and the int32 edges. +// Fractional, NaN and infinite limits take the double path and give the +// same trip counts as Node. + +function bitCount(x: number): number { + x = x - ((x >>> 1) & 0x55555555); + x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); + return Math.imul((x + (x >>> 4)) & 0x0f0f0f0f, 0x01010101) >>> 24; +} + +class Window { + low = 0; + high = 0; + constructor(low: number, high: number) { + this.low = low | 0; + this.high = high | 0; + } +} + +function countUp(from: number, to: number): number { + let trips = 0; + for (let i = from; i < to; i++) trips++; + return trips; +} +function countUpInclusive(from: number, to: number): number { + let trips = 0; + for (let i = from; i <= to; i++) trips++; + return trips; +} +function countDown(from: number, to: number): number { + let trips = 0; + for (let i = from; i > to; i--) trips++; + return trips; +} +function countDownInclusive(from: number, to: number): number { + let trips = 0; + for (let i = from; i >= to; i--) trips++; + return trips; +} + +// An int32 local limit, as in an insertion that shifts the elements after a rank. +function insertAt(values: number[], at: number, value: number): void { + values.push(value); + for (let i = values.length - 1; i > at; i--) values[i] = values[i - 1]!; + values[at] = value; +} +function removeAt(values: number[], at: number): void { + for (let i = at; i < values.length - 1; i++) values[i] = values[i + 1]!; + values.pop(); +} + +const values = [10, 20, 30, 40]; +const rank = bitCount(0b1011 & 0b0011); +insertAt(values, rank, 25); +insertAt(values, 0, 5); +insertAt(values, values.length - 1, 45); +console.log("after inserts", values.join(",")); +removeAt(values, bitCount(1)); +removeAt(values, values.length - 1); +console.log("after removes", values.join(",")); + +// A rank from a method as the limit: a page that keeps its values in id +// order and shifts the later ones on insert and remove. +class Page { + readonly values: number[] = []; + bits = 0; + rank(slot: number): number { + return bitCount(this.bits & ((1 << (slot & 31)) - 1)); + } + set(slot: number, value: number): void { + const bit = 1 << (slot & 31); + const at = this.rank(slot); + const values = this.values; + if ((this.bits & bit) !== 0) { + for (let i = at; i < values.length - 1; i++) values[i] = values[i + 1]!; + values.pop(); + this.bits = (this.bits & ~bit) | 0; + return; + } + values.push(value); + for (let i = values.length - 1; i > at; i--) values[i] = values[i - 1]!; + values[at] = value; + this.bits = (this.bits | bit) | 0; + } + // Inclusive bounds at a rank, in both directions. + suffix(slot: number): number { + const at = this.rank(slot); + const values = this.values; + let total = 0; + for (let i = values.length - 1; i >= at; i--) total += values[i]!; + for (let i = 0; i <= at - 1; i++) total -= values[i]!; + return total; + } +} +const page = new Page(); +for (const slot of [5, 1, 9, 3, 31, 0, 9, 1, 17]) page.set(slot, slot * 10); +console.log("page", page.values.join(","), page.bits >>> 0); +console.log("suffix", page.suffix(0), page.suffix(4), page.suffix(17), page.suffix(31)); + +// Limits from fields, calls and proven ranges. +const window = new Window(-3, 7); +let sum = 0; +for (let i = window.low; i < window.high; i++) sum += i; +console.log("field limits", sum); +sum = 0; +for (let i = window.high; i >= window.low; i--) sum += i; +console.log("field limits down", sum); +let byte = 0; +for (let k = 0; k < 6; k++) { + const limit = (k * 77) & 0xff; + let trips = 0; + for (let i = 0; i <= limit; i += 37) trips++; + byte += trips; +} +console.log("masked limits", byte); +let fromCalls = 0; +for (let i = bitCount(0xff); i > bitCount(0x3); i--) fromCalls++; +console.log("call limits", fromCalls); + +// Edges and empty ranges. +console.log("up", countUp(0, 5), countUp(5, 0), countUp(-5, -2), countUp(2147483645, 2147483647)); +console.log( + "up inclusive", + countUpInclusive(0, 5), + countUpInclusive(3, 3), + countUpInclusive(2147483645, 2147483647), +); +console.log("down", countDown(5, 0), countDown(0, 5), countDown(-2147483646, -2147483648)); +console.log( + "down inclusive", + countDownInclusive(5, 0), + countDownInclusive(-2147483646, -2147483648), + countDownInclusive(0, 0), +); + +// Limits that are not exact integers. +console.log("fractional", countUp(0, 2.5), countUpInclusive(0, 2.5), countDown(3, 0.5), countDownInclusive(3, 0.5)); +console.log("negative fractional", countUp(-3, -0.5), countDownInclusive(0, -2.5)); +console.log("NaN", countUp(0, Number.NaN), countDown(3, Number.NaN)); +let capped = 0; +for (let i = 0; i < Number.POSITIVE_INFINITY; i++) { + if (++capped === 4) break; +} +console.log("infinite", capped); +let floored = 0; +for (let i = 3; i > Number.NEGATIVE_INFINITY; i--) { + if (++floored === 5) break; +} +console.log("negative infinite", floored); diff --git a/tests/corpus/pad-case-converted-strings.ts b/tests/corpus/pad-case-converted-strings.ts new file mode 100644 index 000000000..16d5ca2dc --- /dev/null +++ b/tests/corpus/pad-case-converted-strings.ts @@ -0,0 +1,39 @@ +// padStart and padEnd on the results of case conversion and slicing, with +// ASCII and non-ASCII receivers and fills, and index reads of the results. + +const words = ["alpha", "beta", "gamma", "Delta", "épée", "straße", "日本語", "a😀b"]; +const fills = ["-", "ab", "é", "😀", "", " "]; + +function describe(s: string): string { + return [ + JSON.stringify(s), + s.length, + s.charAt(1), + s.at(-1), + s.indexOf("A"), + s.slice(1, 4), + s.charCodeAt(s.length - 1), + s.codePointAt(0), + ].join(" "); +} + +for (const word of words) { + const upper = word.slice(0, 3).toUpperCase(); + const lower = word.toLowerCase(); + for (const fill of fills) { + console.log(describe(upper.padStart(6, fill))); + console.log(describe(lower.padEnd(9, fill))); + } + console.log(describe(upper.padStart(2)), describe(upper.padEnd(5))); +} + +// The same string padded again, compared and used as a key. +const counts = new Map(); +for (let i = 0; i < 40; i++) { + const key = words[i % words.length]!.slice(0, 3).toUpperCase().padStart(4, "-"); + counts.set(key, (counts.get(key) ?? 0) + 1); +} +console.log([...counts].map(([k, v]) => `${k}=${v}`).join(",")); +const padded = "abc".toUpperCase().padStart(5, "😀"); +console.log(padded.length, [...padded].length, padded === "\ud83dABC".padStart(5, "😀")); +console.log("x".toUpperCase().padEnd(4, "é").normalize("NFD").length); diff --git a/tests/corpus/web-stream-transform-pipes-untyped.mjs b/tests/corpus/web-stream-transform-pipes-untyped.mjs new file mode 100644 index 000000000..b72fec111 --- /dev/null +++ b/tests/corpus/web-stream-transform-pipes-untyped.mjs @@ -0,0 +1,212 @@ +// Web TransformStream and pipes from untyped JavaScript: transformers with +// start, transform, flush and cancel, backpressure between the sides, +// controller errors and terminate, pipeTo and pipeThrough with their +// options, abort signals, own properties on streams and method wrappers +// over them, a transformer start that returns a thenable, and encoder and +// decoder streams, in Node's promise order. +import { TransformStream as WebTransformStream, TransformStreamDefaultController } from "node:stream/web"; + +const log = []; +const out = (...args) => log.push(args.map(String).join(" ")); +const caught = (fn) => { + try { + fn(); + return "ok"; + } catch (e) { + return `${e.name}:${e.code ?? ""}:${e.message}`; + } +}; +const settled = (p) => p.then((v) => `ok:${v}`, (e) => `${e?.name}:${e?.code ?? ""}:${e?.message ?? e}`); +const collect = async (readable) => { + const got = []; + for await (const chunk of readable) got.push(chunk); + return got.join(","); +}; + +out("classes", TransformStream === WebTransformStream, TransformStream.name, TransformStream.length, caught(() => new TransformStreamDefaultController())); +out("prototype", Object.keys(TransformStream.prototype).join(","), Object.keys(TransformStreamDefaultController.prototype).join(",")); + +{ + const order = []; + const t = new TransformStream({ + start(c) { + order.push(`start ${c.desiredSize}`); + c.enqueue("S"); + }, + transform(chunk, c) { + order.push(`transform ${chunk}`); + c.enqueue(chunk.toUpperCase()); + }, + flush(c) { + order.push("flush"); + c.enqueue("F"); + }, + }); + const writer = t.writable.getWriter(); + writer.write("a"); + writer.write("b"); + writer.close(); + out("transform", await collect(t.readable), order.join(" | "), await settled(writer.closed)); +} + +{ + const t = new TransformStream({}, undefined, { highWaterMark: 1 }); + const writer = t.writable.getWriter(); + out("backpressure", writer.desiredSize, t.readable.locked); + const first = writer.write("x"); + const second = writer.write("y"); + out("queued", writer.desiredSize); + const reader = t.readable.getReader(); + out("read", (await reader.read()).value, (await reader.read()).value, await settled(first), await settled(second)); +} + +{ + const failure = new Error("transform-failure"); + const t = new TransformStream({ transform() { throw failure; } }); + const writer = t.writable.getWriter(); + const reader = t.readable.getReader(); + const read = settled(reader.read()); + const write = settled(writer.write("z")); + out("transform-error", await write, await read, await settled(writer.closed)); +} + +{ + /** @type {any} */ + let controller; + const t = new TransformStream({ start(c) { controller = c; } }); + controller.enqueue("before"); + controller.terminate(); + const reader = t.readable.getReader(); + out("terminate", (await reader.read()).value, (await reader.read()).done, await settled(t.writable.getWriter().write("after")), caught(() => controller.enqueue("late"))); +} + +{ + const cancels = []; + const t = new TransformStream({ cancel(reason) { cancels.push(`cancel ${reason}`); } }); + out("cancel", await settled(t.readable.cancel("stop")), cancels.join(","), await settled(t.writable.getWriter().closed)); +} + +{ + const source = ReadableStream.from(["p", "q", "r"]); + const got = []; + const sink = new WritableStream({ write(chunk) { got.push(chunk); } }); + const piped = source.pipeThrough(new TransformStream({ transform(chunk, c) { c.enqueue(chunk + chunk); } })).pipeTo(sink); + out("pipe", await settled(piped), got.join(","), source.locked); +} + +{ + const source = new ReadableStream({ start(c) { c.enqueue(1); c.close(); } }); + const sink = new WritableStream(); + out("prevent-close", await settled(source.pipeTo(sink, { preventClose: true })), sink.locked, await settled(sink.getWriter().write(2))); +} + +{ + const failure = new Error("source-failure"); + const source = new ReadableStream({ start(c) { c.error(failure); } }); + const aborts = []; + const sink = new WritableStream({ abort(reason) { aborts.push(reason === failure); } }); + out("source-error", await settled(source.pipeTo(sink)), aborts.join(",")); +} + +{ + const controller = new AbortController(); + const reason = new Error("signal"); + const source = new ReadableStream({ pull() {}, cancel(e) { out("signal-cancel", e === reason); } }, { highWaterMark: 0 }); + const sink = new WritableStream({ abort(e) { out("signal-abort", e === reason); } }); + const piped = source.pipeTo(sink, { signal: controller.signal }); + await Promise.resolve(); + controller.abort(reason); + out("signal", await settled(piped), source.locked, sink.locked); +} + +{ + const source = ReadableStream.from(["own"]); + const sink = new WritableStream(); + /** @type {any} */ (source).getReader = () => { throw new Error("public reader"); }; + /** @type {any} */ (sink).tag = "kept"; + await source.pipeTo(sink); + out("own-properties", /** @type {any} */ (sink).tag, typeof /** @type {any} */ (source).getReader, caught(() => { /** @type {any} */ (source).locked = true; })); +} + +{ + // An own property that wraps the saved prototype method reaches the + // class's method. + const calls = []; + const wrap = (target, name, method) => { + target[name] = function (...args) { + calls.push(name); + return method.apply(this, args); + }; + }; + const writable = new WritableStream({ write(chunk) { calls.push(`sink:${chunk}`); } }); + wrap(writable, "getWriter", WritableStream.prototype.getWriter); + const writer = writable.getWriter(); + wrap(writer, "write", WritableStreamDefaultWriter.prototype.write); + wrap(writer, "close", WritableStreamDefaultWriter.prototype.close); + await writer.write("a"); + await writer.close(); + const readable = ReadableStream.from(["b"]); + wrap(readable, "getReader", ReadableStream.prototype.getReader); + const reader = readable.getReader(); + wrap(reader, "read", ReadableStreamDefaultReader.prototype.read); + const first = await reader.read(); + const t = new TransformStream({ + transform(chunk, controller) { + wrap(controller, "enqueue", TransformStreamDefaultController.prototype.enqueue); + controller.enqueue(`${chunk}!`); + }, + }); + t.writable.getWriter().write("c"); + const transformed = await t.readable.getReader().read(); + out("wrappers", calls.join(","), first.value, transformed.value, writable.locked); +} + +{ + // A transformer start that returns a thenable: both sides start when it + // settles, and its rejection errors the stream. + const order = []; + const later = { then(resolve) { order.push("then"); setTimeout(() => { order.push("resolve"); resolve(); }, 0); } }; + const t = new TransformStream({ start() { return later; }, transform(chunk, c) { order.push(`transform:${chunk}`); c.enqueue(chunk); } }); + Promise.resolve().then(() => order.push("tick1")).then(() => order.push("tick2")).then(() => order.push("tick3")); + const written = t.writable.getWriter().write("x").then(() => order.push("written")); + const read = await t.readable.getReader().read(); + await written; + out("start-thenable", order.join(","), read.value); + const ticks = []; + const sync = new TransformStream({ start() { return { then(resolve) { ticks.push("then"); resolve(); } }; }, transform(chunk, c) { ticks.push("transform"); c.enqueue(chunk); } }); + Promise.resolve().then(() => ticks.push("tick1")).then(() => ticks.push("tick2")).then(() => ticks.push("tick3")).then(() => ticks.push("tick4")); + sync.writable.getWriter().write("z").then(() => ticks.push("written")); + sync.readable.getReader().read().then(() => ticks.push("read")); + await new Promise((r) => setTimeout(r, 0)); + out("start-thenable-ticks", ticks.join(",")); + const failing = new TransformStream({ start() { return { then(_, reject) { reject(new Error("start failed")); } }; } }); + out("start-thenable-reject", await settled(failing.writable.getWriter().write("y")), await settled(failing.readable.getReader().read())); + const throwing = new TransformStream({ start() { return { get then() { throw new Error("then getter"); } }; } }); + out("start-then-getter", await settled(throwing.writable.getWriter().write("y"))); + const plain = new TransformStream({ start() { return { then: 1 }; } }); + const pending = plain.readable.getReader().read(); + out("start-object", await settled(plain.writable.getWriter().write("p")), (await pending).value); +} + +out( + "invalid", + await settled(ReadableStream.from([]).pipeTo(/** @type {any} */ ({}))), + caught(() => ReadableStream.from([]).pipeThrough(/** @type {any} */ ({}))), + caught(() => new TransformStream({ readableType: "bytes" })), + caught(() => new TransformStream({ transform: 1 })), +); + +{ + const bytes = []; + await ReadableStream.from(["hé", "\ud83d", "\ude00", "\ud800"]) + .pipeThrough(new TextEncoderStream()) + .pipeTo(new WritableStream({ write(chunk) { bytes.push(Array.from(chunk).join(".")); } })); + out("encoder", bytes.join(" | "), new TextEncoderStream().encoding, Object.prototype.toString.call(new TextEncoderStream())); + const text = []; + await ReadableStream.from([new Uint8Array([0xe2, 0x82]), new Uint8Array([0xac, 0x21]), new Uint8Array([0xf0])]) + .pipeThrough(new TextDecoderStream()) + .pipeTo(new WritableStream({ write(chunk) { text.push(chunk); } })); + const decoder = new TextDecoderStream("utf-8", { fatal: true }); + out("decoder", text.join("|"), decoder.encoding, decoder.fatal, decoder.ignoreBOM, caught(() => new TextDecoderStream("nope"))); +} +console.log(log.join("\n")); diff --git a/tests/corpus/web-stream-writable-untyped.mjs b/tests/corpus/web-stream-writable-untyped.mjs new file mode 100644 index 000000000..314d6b37e --- /dev/null +++ b/tests/corpus/web-stream-writable-untyped.mjs @@ -0,0 +1,144 @@ +// Web WritableStream from untyped JavaScript: the classes as constructor +// values, prototypes and brand checks, writers with backpressure and their +// ready and closed promises, queuing strategies, sink errors, close and +// abort ordering, the controller's signal, and argument errors, in Node's +// promise order. +import { WritableStream as WebWritableStream, WritableStreamDefaultWriter, WritableStreamDefaultController } from "node:stream/web"; + +const log = []; +const out = (...args) => log.push(args.map(String).join(" ")); +const caught = (fn) => { + try { + fn(); + return "ok"; + } catch (e) { + return `${e.name}:${e.code ?? ""}:${e.message}`; + } +}; +const settled = (p) => p.then((v) => `ok:${v}`, (e) => `${e?.name}:${e?.code ?? ""}:${e?.message ?? e}`); + +out("classes", typeof WritableStream, WritableStream === WebWritableStream, WritableStream.name, WritableStream.length, WritableStreamDefaultWriter.length); +out("illegal", caught(() => new WritableStreamDefaultController()), caught(() => /** @type {any} */ (WritableStream)())); +out("prototype", Object.keys(WritableStream.prototype).join(","), Object.keys(WritableStreamDefaultWriter.prototype).join(","), Object.keys(WritableStreamDefaultController.prototype).join(",")); + +{ + const order = []; + const chunks = []; + const stream = new WritableStream( + { + start() { order.push("start"); }, + write(chunk, controller) { + order.push(`write ${chunk}`); + chunks.push(chunk); + return new Promise((resolve) => setTimeout(resolve, 1)); + }, + close() { order.push("close"); }, + }, + new CountQueuingStrategy({ highWaterMark: 2 }), + ); + const writer = stream.getWriter(); + out("writer", writer instanceof WritableStreamDefaultWriter, stream.locked, writer.desiredSize, caught(() => stream.getWriter())); + const writes = [writer.write("a"), writer.write("b"), writer.write("c")]; + out("queued", writer.desiredSize, Object.prototype.toString.call(stream), Object.prototype.toString.call(writer)); + let readySettled = false; + writer.ready.then(() => { readySettled = true; }); + await Promise.resolve(); + out("backpressure", readySettled, order.join(" | ")); + for (const w of writes) await w; + await writer.ready; + out("drained", writer.desiredSize, readySettled, chunks.join("")); + await writer.close(); + out("closed", await settled(writer.closed), writer.desiredSize, order.join(" | "), await settled(writer.write("late"))); + writer.releaseLock(); + out("released", stream.locked, await settled(writer.closed), caught(() => writer.desiredSize)); +} + +{ + const sizes = []; + const stream = new WritableStream( + { write() {} }, + { highWaterMark: 10, size(chunk) { sizes.push(chunk.length); return chunk.length; } }, + ); + const writer = stream.getWriter(); + const good = writer.write("abcd"); + out("sizes", writer.desiredSize, sizes.join(",")); + const bad = writer.write({ length: -1 }); + out("bad-size", await settled(good), await settled(bad), await settled(writer.closed), writer.desiredSize); +} + +{ + const failure = new Error("sink-write"); + const stream = new WritableStream({ write() { throw failure; } }); + const writer = stream.getWriter(); + const first = writer.write("x"); + const second = writer.write("y"); + out("write-error", await settled(first), await settled(second), await settled(writer.closed), await settled(writer.ready)); +} + +{ + const log2 = []; + /** @type {any} */ + let controller; + const reason = new Error("stop"); + const stream = new WritableStream({ + start(c) { + controller = c; + c.signal.addEventListener("abort", () => log2.push(`signal ${c.signal.reason === reason}`)); + }, + write(chunk) { log2.push(`write ${chunk}`); }, + abort(e) { log2.push(`abort ${e === reason}`); }, + }); + const writer = stream.getWriter(); + await writer.write("one"); + const abort = writer.abort(reason); + log2.push("aborting"); + out("abort", await settled(abort), log2.join(" | "), controller.signal.aborted, await settled(writer.closed), writer.desiredSize); +} + +{ + /** @type {any} */ + let controller; + const stream = new WritableStream({ start(c) { controller = c; } }); + const writer = stream.getWriter(); + controller.error(new Error("controller-error")); + out("controller-error", await settled(writer.write("z")), await settled(writer.closed), writer.desiredSize, await settled(stream.abort())); +} + +{ + const stream = new WritableStream(); + const close = stream.close(); + out("direct-close", await settled(stream.close()), await settled(close), await settled(stream.abort())); +} + +out( + "brands", + caught(() => WritableStream.prototype.getWriter.call({})), + await settled(WritableStream.prototype.abort.call({})), + await settled(/** @type {any} */ (Object.getOwnPropertyDescriptor(WritableStreamDefaultWriter.prototype, "ready")).get.call({})), + caught(() => /** @type {any} */ (Object.getOwnPropertyDescriptor(WritableStreamDefaultWriter.prototype, "desiredSize")).get.call({})), + caught(() => WritableStreamDefaultController.prototype.error.call({})), +); +out( + "invalid", + caught(() => new WritableStream(null)), + caught(() => new WritableStream({ type: "bytes" })), + caught(() => new WritableStream({ write: 1 })), + caught(() => new WritableStream({ start: 1 })), + caught(() => new WritableStream({}, { highWaterMark: -1 })), + caught(() => new WritableStreamDefaultWriter(/** @type {any} */ ({}))), +); +{ + const classes = { WritableStream, WritableStreamDefaultWriter, TransformStream }; + const isInstance = (value, C) => value instanceof C; + const stream = new WritableStream(); + const transform = new TransformStream(); + out( + "by-value", + isInstance(stream, classes.WritableStream), + isInstance(stream.getWriter(), classes.WritableStreamDefaultWriter), + isInstance(transform, classes.TransformStream), + isInstance(transform.writable, classes.WritableStream), + isInstance(stream, classes.TransformStream), + ); +} +console.log(log.join("\n")); diff --git a/tests/harness/corpus-costs/6890-node-island-timer-refresh/main.ts.json b/tests/harness/corpus-costs/6890-node-island-timer-refresh/main.ts.json new file mode 100644 index 000000000..8affc810d --- /dev/null +++ b/tests/harness/corpus-costs/6890-node-island-timer-refresh/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 23000 } diff --git a/tests/harness/corpus-costs/8363-record-inherited-keys.ts.json b/tests/harness/corpus-costs/8363-record-inherited-keys.ts.json new file mode 100644 index 000000000..8bcdf4a18 --- /dev/null +++ b/tests/harness/corpus-costs/8363-record-inherited-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2000 } diff --git a/tests/harness/corpus-costs/8364-record-nullish-receiver-arm/main.ts.json b/tests/harness/corpus-costs/8364-record-nullish-receiver-arm/main.ts.json new file mode 100644 index 000000000..8bcdf4a18 --- /dev/null +++ b/tests/harness/corpus-costs/8364-record-nullish-receiver-arm/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2000 } diff --git a/tests/harness/corpus-costs/8408-fetch-connection-reuse.ts.json b/tests/harness/corpus-costs/8408-fetch-connection-reuse.ts.json new file mode 100644 index 000000000..a5a948601 --- /dev/null +++ b/tests/harness/corpus-costs/8408-fetch-connection-reuse.ts.json @@ -0,0 +1 @@ +{ "plain": 9400, "san": 30000 } diff --git a/tests/harness/corpus-costs/8409-fetch-compressed-read-after-end.ts.json b/tests/harness/corpus-costs/8409-fetch-compressed-read-after-end.ts.json new file mode 100644 index 000000000..e97925233 --- /dev/null +++ b/tests/harness/corpus-costs/8409-fetch-compressed-read-after-end.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 42400 } diff --git a/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json b/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json new file mode 100644 index 000000000..bac451539 --- /dev/null +++ b/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 2000 } diff --git a/tests/harness/corpus-costs/pad-case-converted-strings.ts.json b/tests/harness/corpus-costs/pad-case-converted-strings.ts.json new file mode 100644 index 000000000..dbdfb1c12 --- /dev/null +++ b/tests/harness/corpus-costs/pad-case-converted-strings.ts.json @@ -0,0 +1 @@ +{ "plain": 3600, "san": 3700 } diff --git a/tests/harness/corpus-costs/web-stream-transform-pipes-untyped.mjs.json b/tests/harness/corpus-costs/web-stream-transform-pipes-untyped.mjs.json new file mode 100644 index 000000000..7b5e4df47 --- /dev/null +++ b/tests/harness/corpus-costs/web-stream-transform-pipes-untyped.mjs.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 55000 } diff --git a/tests/harness/corpus-costs/web-stream-writable-untyped.mjs.json b/tests/harness/corpus-costs/web-stream-writable-untyped.mjs.json new file mode 100644 index 000000000..b5641a69c --- /dev/null +++ b/tests/harness/corpus-costs/web-stream-writable-untyped.mjs.json @@ -0,0 +1 @@ +{ "plain": 3900, "san": 6700 } diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 25427c35f..63944d3f9 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -65,6 +65,7 @@ const KNOWN: Record = { divergence: "Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared", }, + // require() inside a function. "island-web-plumbing": { run: "SC1090" }, "lazy-traps": { divergence: @@ -117,6 +118,7 @@ const KNOWN: Record = { "util-shims": { run: "SC2020" }, // The package redeclares the var it exported: Node copies the first value. "var-export-redeclared": { island: true }, + // `class Upper extends TransformStream`: extending a builtin class from JavaScript. "web-streams": { run: "SC1090" }, "workspace-copied": { island: true }, "workspace-unclean": { build: "SC2013" }, diff --git a/tests/harness/three-library.test.ts b/tests/harness/three-library.test.ts index 23aa4c5e6..b14c9b779 100644 --- a/tests/harness/three-library.test.ts +++ b/tests/harness/three-library.test.ts @@ -7,59 +7,68 @@ import { skipUnownedTest } from "./shard.js"; const fixture = join(import.meta.dirname, "../library-mode/wasm"); const hasClang = spawnSync("clang", ["--version"]).status === 0; +// Release compiles the whole three.js library into one LLVM module that a +// single process optimizes and codegens on one core, which takes 5-10 +// minutes; sanitized dev builds are slower still. Lanes measured 331-581s for +// release against the old 600s budget, so both get 1_200_000ms. The plain dev +// case runs at about a fifth of release and keeps 600_000ms. +function timeoutFor(optimization: "dev" | "release"): number { + return optimization === "release" || process.env["SCRIPTC_SAN"] === "1" ? 1_200_000 : 600_000; +} // The two optimizations take similar time (about 10 minutes each under ASan): // SCRIPTC_TEST_SHARD runs them on different shards (tests/harness/shard.ts). // skipUnownedTest partitions a describe block's tests. describe("three.js native library", () => { beforeEach((context) => skipUnownedTest(context, {})); - test.skipIf(!hasClang).each(["dev", "release"] as const)( - "three.js sends animated geometry, materials and resource lifecycle to a native host in %s", - async (optimization) => { - const directory = await mkdtemp("/tmp/scriptc-three-library-"); - const sanitize = process.env["SCRIPTC_SAN"] === "1"; - try { - const profile = JSON.parse(await readFile(join(fixture, "three.json"), "utf8")); - const profilePath = join(directory, "profile.json"); - await writeFile( - profilePath, - JSON.stringify({ - ...profile, - name: "three-native-embedding", - entry: join(fixture, "three.mjs"), - optimization, - }), - ); - const built = await compileLibrary({ profilePath, outDir: directory, sanitize }); - if (!built.ok) - throw new Error( - built.diagnostics - .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) - .join("\n"), + for (const optimization of ["dev", "release"] as const) { + test.skipIf(!hasClang)( + `three.js sends animated geometry, materials and resource lifecycle to a native host in ${optimization}`, + async () => { + const directory = await mkdtemp("/tmp/scriptc-three-library-"); + const sanitize = process.env["SCRIPTC_SAN"] === "1"; + try { + const profile = JSON.parse(await readFile(join(fixture, "three.json"), "utf8")); + const profilePath = join(directory, "profile.json"); + await writeFile( + profilePath, + JSON.stringify({ + ...profile, + name: "three-native-embedding", + entry: join(fixture, "three.mjs"), + optimization, + }), ); - const binary = join(directory, "host"); - const linked = spawnSync( - "clang", - [ - "-std=c11", - ...(sanitize ? ["-fsanitize=address,undefined"] : []), - join(fixture, "three-native.c"), - built.archivePath, - "-lm", - "-o", - binary, - ], - { encoding: "utf8" }, - ); - expect(linked.status, linked.stderr).toBe(0); - const actual = spawnSync(binary, [], { encoding: "utf8", timeout: 30_000 }); - expect(actual.status, actual.stderr).toBe(0); - const reference = spawnSync( - process.execPath, - [ - "--no-warnings", - "--input-type=module", - "-e", - ` + const built = await compileLibrary({ profilePath, outDir: directory, sanitize }); + if (!built.ok) + throw new Error( + built.diagnostics + .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) + .join("\n"), + ); + const binary = join(directory, "host"); + const linked = spawnSync( + "clang", + [ + "-std=c11", + ...(sanitize ? ["-fsanitize=address,undefined"] : []), + join(fixture, "three-native.c"), + built.archivePath, + "-lm", + "-o", + binary, + ], + { encoding: "utf8" }, + ); + expect(linked.status, linked.stderr).toBe(0); + const actual = spawnSync(binary, [], { encoding: "utf8", timeout: 30_000 }); + expect(actual.status, actual.stderr).toBe(0); + const reference = spawnSync( + process.execPath, + [ + "--no-warnings", + "--input-type=module", + "-e", + ` const events=[]; for (const name of ["vertex","surface","resource"]) globalThis[name]=(...values)=>events.push([name,...values.map(value=>Number(value.toFixed(9)))]); const {frame,dispose}=await import(${JSON.stringify(join(fixture, "three.mjs"))}); @@ -67,21 +76,22 @@ describe("three.js native library", () => { dispose(); console.log(JSON.stringify(events)); `, - ], - { encoding: "utf8" }, - ); - expect(reference.status, reference.stderr).toBe(0); - const events = JSON.parse(reference.stdout); - expect( - actual.stdout - .trim() - .split("\n") - .map((line) => JSON.parse(line)), - ).toEqual([...events, ...events]); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }, - 600_000, - ); + ], + { encoding: "utf8" }, + ); + expect(reference.status, reference.stderr).toBe(0); + const events = JSON.parse(reference.stdout); + expect( + actual.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ).toEqual([...events, ...events]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + timeoutFor(optimization), + ); + } }); diff --git a/tests/harness/zero-cost-gate.test.ts b/tests/harness/zero-cost-gate.test.ts index e99eca8d1..77da7e5b9 100644 --- a/tests/harness/zero-cost-gate.test.ts +++ b/tests/harness/zero-cost-gate.test.ts @@ -3,17 +3,22 @@ import { expect, test } from "vitest"; import { cachedComparison, cachegrindCounts, + changedFunctions, compareArtifact, compareCounts, compareExecutables, + confinedToRuntime, definedFunctionNames, dynamicTierSymbols, + linkedRuntimeFiles, + llvmFunctionHashes, mergeReports, nmOutput, programInputsHash, programLines, programVerdict, recordedBaseline, + runtimePackDiff, timingVerdict, } from "../../scripts/zero-cost-compare.mjs"; @@ -198,6 +203,116 @@ test("the zero-cost gate fails differing programs unless they match the signatur expect(verdict({}, true, ok, failed)).toEqual({ verdict: "build-error", fails: true }); }); +test("the zero-cost gate classifies runtime-only and nondeterministic differences", () => { + const ok = { ok: true }; + const exeOnly = { ir: { identical: true }, llvm: { identical: true }, exe: { identical: false } }; + const llvmToo = { + ir: { identical: true }, + llvm: { identical: false }, + exe: { identical: false }, + }; + const verdict = (artifacts: object, flags: object) => + programVerdict({ baseline: ok, candidate: ok, artifacts, allowed: false, ...flags }); + expect(verdict(exeOnly, { runtimeOnly: true })).toEqual({ + verdict: "runtime-only", + fails: false, + }); + expect(verdict(exeOnly, { runtimeOnly: true, strict: true })).toEqual({ + verdict: "runtime-only", + fails: true, + }); + // A changed runtime cannot explain different LLVM. + expect(verdict(llvmToo, { runtimeOnly: true })).toEqual({ verdict: "differs", fails: true }); + expect(verdict(llvmToo, { nondeterministic: ["llvm", "exe"] })).toEqual({ + verdict: "nondeterministic", + fails: false, + }); + expect(verdict(llvmToo, { nondeterministic: ["llvm", "exe"], strict: true })).toEqual({ + verdict: "nondeterministic", + fails: true, + }); + // A nondeterministic executable does not excuse a deterministic LLVM difference. + expect(verdict(llvmToo, { nondeterministic: ["exe"] })).toEqual({ + verdict: "differs", + fails: true, + }); +}); + +test("the zero-cost gate reads the symbols of changed runtime objects and vendor archives", () => { + expect( + linkedRuntimeFiles({ + changed: [ + "artifacts/release/runtime/default/scr_a.o", + "artifacts/release/runtime/default/scr_a.bc", + ], + added: ["artifacts/vendor/libz/libscriptc-zlib.a"], + removed: [], + }), + ).toEqual([ + "artifacts/release/runtime/default/scr_a.o", + "artifacts/vendor/libz/libscriptc-zlib.a", + ]); +}); + +test("the zero-cost gate names the runtime pack files and functions that changed", () => { + const unit = (sha: string, bc: string) => ({ + path: "artifacts/release/runtime/default/scr_a.o", + sha256: sha, + bitcode: { path: "artifacts/release/runtime/default/scr_a.bc", sha256: bc }, + }); + const pack = (a: object, extra: object[] = []) => ({ + flavors: { release: { runtime_units: [{ source: "scr_a.c", variants: [a, ...extra] }] } }, + archives: [{ path: "artifacts/vendor/libz.a", sha256: "z" }], + }); + const other = { path: "artifacts/release/runtime/default/scr_b.o", sha256: "b" }; + expect(runtimePackDiff(pack(unit("1", "2")), pack(unit("1", "2")))).toEqual({ + changed: [], + added: [], + removed: [], + }); + expect(runtimePackDiff(pack(unit("1", "2")), pack(unit("3", "4"), [other]))).toEqual({ + changed: [ + "artifacts/release/runtime/default/scr_a.bc", + "artifacts/release/runtime/default/scr_a.o", + ], + added: ["artifacts/release/runtime/default/scr_b.o"], + removed: [], + }); + + const ll = (body: string, attrs: number) => + [ + `define i32 @scr_same(i32 %x) #${attrs} {`, + " ret i32 %x, !dbg !" + attrs, + "}", + "define internal void @scr_changed() {", + ` ${body}`, + "}", + "", + ].join("\n"); + const before = llvmFunctionHashes(ll("ret void", 0)); + const after = llvmFunctionHashes( + ll("call void @f()\n ret void", 7) + 'define void @"scr new"() {\n}\n', + ); + // Renumbered attribute groups and metadata are not changes. + expect(changedFunctions(before, after)).toEqual({ + changed: ["scr_changed"], + added: ["scr new"], + removed: [], + }); +}); + +test("the zero-cost gate confines runtime-only differences to runtime symbols", () => { + const exe = (added: string[], removed: string[], resized: string[]) => ({ + added, + removed, + resized: resized.map((name) => ({ name, baseline: 1, candidate: 2 })), + }); + const runtime = new Set(["scr_a", "scr_b"]); + expect(confinedToRuntime(exe([], [], ["scr_a"]), runtime)).toBe(true); + expect(confinedToRuntime(exe(["scr_b"], ["scr_a"], []), runtime)).toBe(true); + expect(confinedToRuntime(exe([], [], ["main.$1"]), runtime)).toBe(false); +}); + test("a recorded baseline settles identical programs and shared build errors", () => { const hashes = { ir: "a", llvm: "b", exe: "c" }; const entry = { ok: true, ms: 12, hashes, exeBytes: 100 }; @@ -298,4 +413,15 @@ test("merged gate shards judge compile time and comparisons over every shard", ( "no program built with both checkouts, so nothing was compared", ]); expect(() => mergeReports([fast, fast])).toThrow(/more than one gate report/); + // A runtime-only program was compared, and its line names the runtime change. + const runtimeOnly = { + id: "bench/r", + verdict: "runtime-only", + artifacts: { exe: { identical: false, first: "resized scr_a" } }, + runtime: { changed: ["scr_a"], added: [] }, + }; + expect(mergeReports([shard([runtimeOnly], [])]).report.verdict).toBe("pass"); + expect(programLines(runtimeOnly)).toEqual([ + "runtime bench/r exe: resized scr_a; runtime-only: links 1 changed runtime function (scr_a)", + ]); });