From 0d27008899341b6d000e290ee675331cb12db620 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 15:16:14 -0700 Subject: [PATCH 01/22] Classify zero-cost gate differences instead of failing every runtime change Any change to the runtime's C sources makes every executable differ, so the gate failed even when every program's IR and LLVM were byte-identical. The gate now classifies each difference outside --allow: - runtime-only: IR and LLVM identical, the same LLVM helper, runtime pack objects that differ between the checkouts (bitcode alone records the checkout path, so it does not count), and every symbol the executable gains, loses or resizes defined by a changed runtime object. It passes with a note that lists the changed runtime functions the program links (from the release and speed bitcode, through llvm-dis) and the size change. - nondeterministic: the baseline builds the program again, and its two builds differ in the same artifacts. It passes with a note. - differs: anything else; it fails as before. --strict fails runtime-only and nondeterministic programs too. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- scripts/zero-cost-compare.mjs | 94 +++++++++- scripts/zero-cost-gate.mjs | 255 +++++++++++++++++++++++++-- tests/harness/zero-cost-gate.test.ts | 93 ++++++++++ 4 files changed, 426 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2350d7337..324ca87cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,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/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index df407bbb7..f8e4237fe 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -487,6 +487,78 @@ 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(), + }; +} + +/** 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), + ); +} + /* ── verdicts ──────────────────────────────────────────────────────────── */ /** One program's verdict from its build results and artifact comparisons. @@ -494,7 +566,21 @@ export function compareCounts(baseline, candidate) { /** `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". Neither fails the gate unless `strict` is set. */ +export function programVerdict({ + baseline, + candidate, + artifacts, + allowed, + required = false, + runtimeOnly = false, + nondeterministic = false, + strict = false, +}) { if (!baseline.ok || !candidate.ok) { if (!baseline.ok && !candidate.ok && baseline.error === candidate.error) return { verdict: "both-fail", fails: required }; @@ -502,7 +588,11 @@ 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 (nondeterministic) return { verdict: "nondeterministic", fails: strict }; + return { verdict: "differs", fails: true }; } export function median(values) { diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index 99b1bc468..c756dd2ea 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 { @@ -30,17 +34,23 @@ import { parseArgs } from "node:util"; import { readWorkloads } from "../benchmarks/runtime/workloads.mjs"; import { cachegrindCounts, + changedFunctions, compareArtifact, compareCounts, compareExecutables, + confinedToRuntime, DYNAMIC_WORKLOADS, definedFunctionNames, dynamicTierSymbols, + llvmFunctionHashes, median, nmOutput, + parseNm, programVerdict, + runtimePackDiff, timeRatio, } from "./zero-cost-compare.mjs"; +import { createHash } from "node:crypto"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const { values } = parseArgs({ @@ -54,6 +64,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" }, @@ -69,7 +80,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] @@ -84,8 +95,20 @@ JavaScript entries). --quick is --set=bench --timing-runs=0, for CI. --shard=i/n keeps every n-th corpus program; --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 @@ -260,10 +283,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}`) @@ -400,6 +422,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 objects) + 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) { @@ -416,6 +543,7 @@ const pct = (ratio) => `${ratio >= 1 ? "+" : ""}${((ratio - 1) * 100).toFixed(2) const programs = goodCodeSet(); assert.ok(programs.length > 0, "the selected good-code set is empty"); +const runtime = runtimeChanges(); const report = { schema: 1, date: new Date().toISOString(), @@ -425,6 +553,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: [], @@ -433,6 +571,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(); @@ -450,13 +606,39 @@ await pool( const artifacts = 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(false); + // 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(true)); + } const entry = { id: program.id, verdict, @@ -464,6 +646,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 (!baseline.ok) entry.baselineError = baseline.error; if (!candidate.ok) entry.candidateError = candidate.error; const size = artifacts.exe?.bytes; @@ -517,6 +701,8 @@ function printProgram(entry) { identical: "identical", allowed: "allowed ", differs: "DIFFERS ", + "runtime-only": "runtime ", + nondeterministic: "nondet ", "build-error": "ERROR ", "both-fail": "both-fail", }[entry.verdict]; @@ -543,6 +729,17 @@ function printProgram(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}`); console.log(`${label} ${entry.id}${notes.length ? ` ${notes.join("; ")}` : ""}`); // A build error's first line is often a warning; print all of it. @@ -631,7 +828,8 @@ if (timingRuns > 0) { 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; report.failures.push("no program built with both checkouts, so nothing was compared"); } @@ -644,10 +842,37 @@ 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)}`, ); +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/harness/zero-cost-gate.test.ts b/tests/harness/zero-cost-gate.test.ts index 565f8ba71..734757c28 100644 --- a/tests/harness/zero-cost-gate.test.ts +++ b/tests/harness/zero-cost-gate.test.ts @@ -2,13 +2,17 @@ import { spawnSync } from "node:child_process"; import { expect, test } from "vitest"; import { cachegrindCounts, + changedFunctions, compareArtifact, compareCounts, compareExecutables, + confinedToRuntime, definedFunctionNames, dynamicTierSymbols, + llvmFunctionHashes, nmOutput, programVerdict, + runtimePackDiff, } from "../../scripts/zero-cost-compare.mjs"; const ir = (functions: object[], globals: object[]) => @@ -191,3 +195,92 @@ test("the zero-cost gate fails differing programs unless they match the signatur expect(verdict({}, false, failed, failed, true)).toEqual({ verdict: "both-fail", fails: true }); 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: true })).toEqual({ + verdict: "nondeterministic", + fails: false, + }); + expect(verdict(llvmToo, { nondeterministic: true, strict: true })).toEqual({ + verdict: "nondeterministic", + fails: true, + }); +}); + +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); +}); From 7dfe5e703301b5ac883e8aca18b614c49aea9375 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 13:54:24 -0700 Subject: [PATCH 02/22] Keep the borrowed dispatch path through the inherited-method fallback The keyed read of a record of functions is now `r[k] ?? inherited(r, k)`. A pass-through `??` over a pointer keyed read whose stored values are never unit lends the hit like the plain read: canBorrowReceiver and ReferenceWrites.readKeys see through it, a default that only returns interned function values or undefined needs no owner (so `const h = r[k]` still aliases the entry), and a bound default is owned by a statement-frame slot whose release skips the call while it stays empty. The miss helper stays out of line, and the impls avoid code that would make every indirect call look like a heap writer. An emission test pins that function-table dispatch neither boxes, retains nor looks up through the runtime. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/emit-function.ts | 3 + packages/compiler/src/backend/llvm/emitter.ts | 30 ++++++- .../compiler/src/backend/llvm/expr-context.ts | 2 + .../compiler/src/backend/llvm/expr-records.ts | 87 ++++++++++++++++++- .../compiler/src/backend/llvm/receivers.ts | 11 +++ .../src/backend/llvm/reference-writes.ts | 4 + .../src/frontend/lowering/record-inherited.ts | 65 ++++++++++---- .../function-table-dispatch-emission.test.ts | 79 +++++++++++++++++ 8 files changed, 259 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/function-table-dispatch-emission.test.ts diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 8c9ace9f3..01622794a 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -655,6 +655,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 5e48b602a..8475a3853 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -292,8 +292,10 @@ import { emitBytesIntrinsic, } from "./expr-bytes.js"; import { + borrowedRecordKeyGetOr, emitRecordKeyGet, emitRegexIntrinsic, + immortalDefault, keyedRecordReadInto, pointerRecordKeyGet, } from "./expr-records.js"; @@ -3377,8 +3379,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 @@ -3489,6 +3497,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); } @@ -6038,6 +6057,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; @@ -7313,6 +7337,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 ac5262571..e6f534dc1 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -22,6 +22,8 @@ export interface LlValue { * use it in place of a round trip (integer-values.ts `indexInteger`). */ wideInteger?: WideInteger; 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; } diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index af4bb092f..df4eb48a0 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 @@ -468,6 +476,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") @@ -477,6 +486,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) { @@ -506,8 +524,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(); @@ -515,6 +548,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/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index 8524a51fb..80cff7a8c 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); @@ -315,6 +320,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/frontend/lowering/record-inherited.ts b/packages/compiler/src/frontend/lowering/record-inherited.ts index 65d070f1d..320f461c6 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, and their keys convert without running + * dynamic-tier code (which would make every indirect call a 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,16 +544,43 @@ 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 }; - return { - kind: "libCall", - fn: "dyn.toStringCoerce", - args: [lowerer.coerceToExpected(arg, DYN)], - 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 a class instance without its own toString, spells + // "[object Object]" without running any code. + if ( + type.kind === "record" || + (type.kind === "object" && defaultToString(lowerer, type.className)) + ) + return { kind: "strLit", value: "[object Object]", type: STRING, loc }; + // A checked value converts through the dynamic tier it already lives in. + if (type.kind === "dyn") + return { kind: "libCall", fn: "dyn.toStringCoerce", args: [arg], type: STRING, loc }; + // Other keys (arrays, functions, class instances with toString) would run + // code; the built-in answers the checked-conversion TypeError for them. + return nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, STRING, loc); +} + +/** 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 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..584a9efaf --- /dev/null +++ b/packages/compiler/test/function-table-dispatch-emission.test.ts @@ -0,0 +1,79 @@ +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 } from "../src/index.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"); +} + +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/); + } +}); From 89e2daa636e3c82f0f147c5353b28c20f2f63b19 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 19:42:59 -0500 Subject: [PATCH 03/22] Keep borrowed fallbacks alive until their consumer and address review feedback --- .../compiler/src/backend/llvm/receivers.ts | 20 +++++- .../src/frontend/lowering/record-inherited.ts | 40 ++++++++---- .../function-table-dispatch-emission.test.ts | 63 ++++++++++++++++++- .../corpus/8363-record-inherited-keys.ts.json | 6 ++ .../main.ts.json | 6 ++ tests/corpus/8363-record-inherited-keys.ts | 51 +++++++++++++++ .../8364-record-nullish-receiver-arm/main.ts | 29 +++++++++ .../tsconfig.json | 6 ++ .../8363-record-inherited-keys.ts.json | 1 + .../main.ts.json | 1 + 10 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8363-record-inherited-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8364-record-nullish-receiver-arm/main.ts.json create mode 100644 tests/corpus/8363-record-inherited-keys.ts create mode 100644 tests/corpus/8364-record-nullish-receiver-arm/main.ts create mode 100644 tests/corpus/8364-record-nullish-receiver-arm/tsconfig.json create mode 100644 tests/harness/corpus-costs/8363-record-inherited-keys.ts.json create mode 100644 tests/harness/corpus-costs/8364-record-nullish-receiver-arm/main.ts.json diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index 80cff7a8c..78a9b67ad 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -165,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_], @@ -173,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}`); diff --git a/packages/compiler/src/frontend/lowering/record-inherited.ts b/packages/compiler/src/frontend/lowering/record-inherited.ts index 320f461c6..ca80a4f4f 100644 --- a/packages/compiler/src/frontend/lowering/record-inherited.ts +++ b/packages/compiler/src/frontend/lowering/record-inherited.ts @@ -523,9 +523,9 @@ function builtinAnswer( /** 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, and their keys convert without running - * dynamic-tier code (which would make every indirect call a heap writer to - * ReferenceWrites). */ + * 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; @@ -557,19 +557,35 @@ function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc): ) ) return { kind: "toString", operand: arg, type: STRING, loc }; - // A record, or a class instance without its own toString, spells - // "[object Object]" without running any code. + // A record or class instance that answers Object.prototype.toString + // spells "[object Object]" without running any code. if ( - type.kind === "record" || + (type.kind === "record" && defaultRecordToString(lowerer, type.shapeId)) || (type.kind === "object" && defaultToString(lowerer, type.className)) ) return { kind: "strLit", value: "[object Object]", type: STRING, loc }; - // A checked value converts through the dynamic tier it already lives in. - if (type.kind === "dyn") - return { kind: "libCall", fn: "dyn.toStringCoerce", args: [arg], type: STRING, loc }; - // Other keys (arrays, functions, class instances with toString) would run - // code; the built-in answers the checked-conversion TypeError for them. - return nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, 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", + args: [lowerer.coerceToExpected(arg, DYN)], + type: STRING, + loc, + }; +} + +/** 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. */ diff --git a/packages/compiler/test/function-table-dispatch-emission.test.ts b/packages/compiler/test/function-table-dispatch-emission.test.ts index 584a9efaf..65e46f18b 100644 --- a/packages/compiler/test/function-table-dispatch-emission.test.ts +++ b/packages/compiler/test/function-table-dispatch-emission.test.ts @@ -2,7 +2,9 @@ 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 } from "../src/index.js"; +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 () => { @@ -20,6 +22,17 @@ async function emit(source: string): Promise { 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, @@ -77,3 +90,51 @@ test("function-table dispatch borrows the stored closure on a hit", async () => 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/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/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/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 } From fefbf3ac35958a1349219c6a54156dcf72b76451 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 22:25:51 -0500 Subject: [PATCH 04/22] Reuse keep-alive connections across fetch() requests The native fetch dialed a new connection for every request and closed it after the response, so a loop of requests to one origin paid a TCP connect, and for https a TLS handshake, per request. Node's fetch keeps connections alive through undici's dispatcher. A direct request now goes through a keep-alive agent private to fetch (scr_http_fetch_request): an idle connection for the same host, port and bare hostname serves the next request, there is no limit on connections per origin, and an idle connection closes after 4 s or the server's Keep-Alive hint less one second, without keeping the process alive. Proxied requests still dial their own connection. When a response ends, the transfer resumes its socket if it had paused it and stops pausing and resuming it, since the agent may hand the socket to the next request. scr_http_request_agent_ex and http.Agent creation share their agent logic with the new entry point. --- .../8408-fetch-connection-reuse.ts.json | 6 + packages/runtime/src/scr_fetch.c | 25 ++- packages/runtime/src/scr_http.c | 158 ++++++++++++------ packages/runtime/src/scr_http.h | 8 + tests/corpus/8408-fetch-connection-reuse.ts | 95 +++++++++++ .../8408-fetch-connection-reuse.ts.json | 1 + 6 files changed, 237 insertions(+), 56 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8408-fetch-connection-reuse.ts.json create mode 100644 tests/corpus/8408-fetch-connection-reuse.ts create mode 100644 tests/harness/corpus-costs/8408-fetch-connection-reuse.ts.json 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/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 1b30c451d..652dfa8f1 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -5386,9 +5386,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) { @@ -5929,9 +5941,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/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/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 } From 5a1bc2a6d52c627f5fda71b608e6537adf2ee90c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 01:56:31 -0500 Subject: [PATCH 05/22] Drain a compressed fetch body that is still buffered when the response ends Since the transfer drops its socket at the response's 'end', resuming a read no longer reached the decoder: sf_response_resume_if_ready returned before sf_inflate_resume once the transfer held no paused socket. A compressed response whose whole body arrived before it was read then kept its remaining decoded output buffered, and later reads stayed pending. The buffered input and decoder output now drain on demand after 'end' as well as while the socket is paused. --- packages/runtime/src/scr_fetch.c | 12 ++-- .../8409-fetch-compressed-read-after-end.ts | 66 +++++++++++++++++++ ...09-fetch-compressed-read-after-end.ts.json | 1 + 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/corpus/8409-fetch-compressed-read-after-end.ts create mode 100644 tests/harness/corpus-costs/8409-fetch-compressed-read-after-end.ts.json diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 652dfa8f1..189e97f84 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -1129,15 +1129,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)); } 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/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 } From 2bbb855f1f5756ca5db4955a7137d044f50e4e52 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 02:09:51 -0500 Subject: [PATCH 06/22] Record the preflight and order baseline of the compressed fetch body program --- .../corpus/8409-fetch-compressed-read-after-end.ts.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8409-fetch-compressed-read-after-end.ts.json 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": [] +} From cb572a7b6718e913b8a42b1e62cc09938f8bc6c1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 02:44:56 -0500 Subject: [PATCH 07/22] Pad ASCII strings without indexing their UTF-16 units `toUpperCase` and `toLowerCase` now record that a result of their ASCII path is ASCII. `padStart` and `padEnd` take the UTF-16 lengths of the receiver and the fill the way `length` does: a proven-ASCII or short string is counted directly instead of claiming a string-index entry. A one-unit fill repeats without a division, a one-byte fill with memset, and a result padded from an ASCII receiver and fill is recorded as ASCII. `word.slice(0, 3).toUpperCase().padStart(4, "-")` previously built an index entry for the three-byte receiver on every call. --- .../corpus/pad-case-converted-strings.ts.json | 6 +++ packages/runtime/src/scr_regex.c | 1 + packages/runtime/src/scr_string.c | 28 ++++++++++--- tests/corpus/pad-case-converted-strings.ts | 39 +++++++++++++++++++ .../pad-case-converted-strings.ts.json | 1 + 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pad-case-converted-strings.ts.json create mode 100644 tests/corpus/pad-case-converted-strings.ts create mode 100644 tests/harness/corpus-costs/pad-case-converted-strings.ts.json 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/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/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/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 } From a75cff7ab4aadc488113084a2a798d0cbfda7dca Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 13:49:48 -0500 Subject: [PATCH 08/22] Use an exact integer loop limit as the counted loop's endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A counted for loop converts its limit to an i64 endpoint. A limit that is not a literal went through the double every time: converted to double, clamped to 2^53, rounded with llvm.floor or llvm.ceil and converted back. At the default target CPU the rounding is a libm call, which a loop such as `for (let i = values.length - 1; i > at; i--)` paid on every entry even when `at` was an int32 local. 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 now the endpoint directly; other limits keep the double path. --- .../src/backend/llvm/counted-loops.ts | 13 +- .../test/counted-loop-emission.test.ts | 33 ++++ .../counted-loop-integer-limits.ts.json | 6 + tests/corpus/counted-loop-integer-limits.ts | 152 ++++++++++++++++++ .../counted-loop-integer-limits.ts.json | 1 + 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json create mode 100644 tests/corpus/counted-loop-integer-limits.ts create mode 100644 tests/harness/corpus-costs/counted-loop-integer-limits.ts.json diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 88af25e47..5d21e56f7 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,14 @@ export function emitCountedLoopLimit( } const B = host.B; const value = host.emitExpr(loop.limit); + const exact = exactInteger(host, value, loop.limit); + if (exact) { + const integer = /^-?\d+$/.test(exact.name) ? exact.name : 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/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/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/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/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 } From fef45262f8b7a285404741421532a2c8f3828f77 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 14:06:44 -0500 Subject: [PATCH 09/22] Read a literal loop limit back with its integer's signedness exactInteger can answer a limit's ToUint32 view, which for a number literal is a literal of its unsigned bits: -1 has the view 4294967295. The counted loop took such a literal as its i64 endpoint as written, so a negative limit became a large positive endpoint and the loop ran when it should not. An i32 literal endpoint is now read as signed or unsigned, as the integer says. --- .../src/backend/llvm/counted-loops.test.ts | 46 +++++++++++++++++++ .../src/backend/llvm/counted-loops.ts | 12 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/backend/llvm/counted-loops.test.ts 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 5d21e56f7..454fea32a 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -40,7 +40,17 @@ export function emitCountedLoopLimit( const value = host.emitExpr(loop.limit); const exact = exactInteger(host, value, loop.limit); if (exact) { - const integer = /^-?\d+$/.test(exact.name) ? exact.name : widenInteger(host, 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}`); From b3453591c87dccf71397bb4795851d058de4c34b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 14:29:41 -0500 Subject: [PATCH 10/22] Time emission's serial steps and remove three repeated walks --- .../src/backend/llvm/call-lifetimes.ts | 19 ++- packages/compiler/src/backend/llvm/emitter.ts | 21 +++- .../src/backend/llvm/local-extents.ts | 112 ++++++++++++++++++ .../src/backend/llvm/rebound-parameters.ts | 15 +++ .../compiler/src/backend/llvm/unwind-abi.ts | 46 ++++--- packages/compiler/src/index.ts | 2 + packages/compiler/src/native/prepare.ts | 2 + 7 files changed, 197 insertions(+), 20 deletions(-) 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/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 003c2a278..819782aa0 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"; @@ -522,6 +523,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. */ @@ -703,6 +709,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( @@ -734,6 +741,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 @@ -755,7 +763,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 @@ -2123,7 +2139,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; @@ -2216,6 +2232,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); 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/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 821c5351b..1c75a6b82 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,15 @@ 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, + eachNodeOfKinds(fn, virtualCalls, (node) => { + const e = node as 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; }); // A hierarchy's vtables hold the methods declared by more than one of its // classes (an override and what it overrides). @@ -161,9 +168,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 +200,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 +239,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/index.ts b/packages/compiler/src/index.ts index 39091c1dc..b002e2437 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1035,6 +1035,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 } : {}), @@ -2545,6 +2546,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/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"); From 5dc4a4daa613019fee32f8775fa99bb9e2eccedd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 15:04:45 -0500 Subject: [PATCH 11/22] Map large executables' text with 2 MiB pages --- docs/content/docs/platforms.mdx | 2 + .../compiler/src/backend/huge-text.test.ts | 89 ++++++++++++++ packages/compiler/src/backend/huge-text.ts | 110 ++++++++++++++++++ packages/compiler/src/backend/link-plan.ts | 9 ++ packages/compiler/src/backend/linker.ts | 4 + packages/compiler/src/native/compiler.ts | 10 ++ packages/runtime/src/scr_lib.c | 42 +++++++ 7 files changed, 266 insertions(+) create mode 100644 packages/compiler/src/backend/huge-text.test.ts create mode 100644 packages/compiler/src/backend/huge-text.ts 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/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/native/compiler.ts b/packages/compiler/src/native/compiler.ts index b46778827..19351a81a 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"; @@ -637,11 +642,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 +688,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/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 From 62d4cecd3aa179773c1e213602e3be0ce8e99b81 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 15:22:08 -0500 Subject: [PATCH 12/22] Keep frozen checked records frozen, and give sloppy-mode this the global object --- .../builtin-apis/object-freezing.mdx | 1 + .../language-features/function-values.mdx | 2 + packages/compiler/src/backend/llvm/dyn.ts | 15 ++++++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-writes.ts | 2 + .../src/frontend/lowering/frozen-records.ts | 29 ++++++++---- .../src/frontend/lowering/lower-functions.ts | 21 ++++++--- .../compiler/src/ir/builtin-signatures.ts | 3 ++ ...412-frozen-checked-records-sloppy.cjs.json | 6 +++ .../8413-frozen-checked-records.ts.json | 6 +++ .../corpus/8414-sloppy-this-global.cjs.json | 6 +++ packages/runtime/src/scr_dyn.h | 3 ++ packages/runtime/src/scr_node_builtin.c | 5 ++ .../8412-frozen-checked-records-sloppy.cjs | 37 +++++++++++++++ tests/corpus/8413-frozen-checked-records.ts | 25 ++++++++++ tests/corpus/8414-sloppy-this-global.cjs | 46 +++++++++++++++++++ 16 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json create mode 100644 tests/corpus/8412-frozen-checked-records-sloppy.cjs create mode 100644 tests/corpus/8413-frozen-checked-records.ts create mode 100644 tests/corpus/8414-sloppy-this-global.cjs diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 030b03b4b..80281bb2d 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,6 +5,7 @@ A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way. The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 7083f865d..32af01186 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -5,3 +5,5 @@ - **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them. A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element. Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction. + A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node. + Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there). diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb..cf42d6a98 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2889,6 +2889,21 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${ek})`); }); } + // A record built from a frozen object is frozen too, as the object it + // copies (frozen-records.ts makes such shapes freezable). + if (shape.freezable) { + host.declare(`declare zeroext i1 @scr_dyn_is_frozen(ptr)`); + host.declare(`declare void @scr_record_freeze(ptr)`); + const frozen = B.tmp(); + const lFreeze = B.newLabel("dcr.freeze"); + const lDone = B.newLabel("dcr.frozen"); + B.line(`${frozen} = call i1 @scr_dyn_is_frozen(ptr %d)`); + B.condBr(frozen, lFreeze, lDone); + B.startBlock(lFreeze); + B.line(`call void @scr_record_freeze(ptr %r0)`); + B.br(lDone); + B.startBlock(lDone); + } B.terminate(`ret ptr %r0`); break; } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 0271664ac..c4eacd7af 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1127,6 +1127,7 @@ export const LIB_FN_SYMS: Record = { "dyn.dataViewIs": "scr_dyn_data_view_is", "dyn.construct": "scr_dyn_construct", "dyn.this": "scr_dyn_this_get", + "dyn.sloppyReceiver": "scr_dyn_sloppy_receiver", "dyn.generatorThis": "scr_gen_receiver", "insp.dyn": "scr_insp_dyn", "insp.dynS": "scr_insp_dyn_s", diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 79ac98a7d..fedc5b4c3 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -94,6 +94,8 @@ const INERT_LIB_CALLS = new Set([ "dyn.compare", // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", + // A sloppy-mode function's receiver from it (a fresh reference). + "dyn.sloppyReceiver", "error.new", "error.nodeThrow", "dyn.typedRefIs", diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 3700b57ff..75056b3cc 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -121,6 +121,8 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; + const checked: IrType[] = []; + let freezesUntyped = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -130,19 +132,28 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true; + else if (e.kind === "dynFrom") live.push(e.value.type); + else if (e.kind === "dynCheck") checked.push(e.type); return true; }, stmt: () => true, }); - if (!lowerer.untypedIntegrity) return { shapes, arrays }; - // An untyped reference to a class instance, or to a record or array - // boxed live, reaches the values its fields and elements hold as further - // live references. Class instances also cross into untyped code through - // compiled callbacks, so every class is a root. - const stack = [...live]; - for (const info of lowerer.classes.values()) - for (const field of info.def.fields) stack.push(field.type); + if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; + const stack: IrType[] = []; + // A record checked from an untyped value copies it, frozen when the value + // is (a fresh literal frozen in a JavaScript file included), so the shapes + // a check builds are freezable in a program that freezes untyped values. + if (freezesUntyped) stack.push(...checked); + if (lowerer.untypedIntegrity) { + // An untyped reference to a class instance, or to a record or array + // boxed live, reaches the values its fields and elements hold as further + // live references. Class instances also cross into untyped code through + // compiled callbacks, so every class is a root. + stack.push(...live); + for (const info of lowerer.classes.values()) + for (const field of info.def.fields) stack.push(field.type); + } const seen = new Set(); while (stack.length > 0) { const type = stack.pop()!; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 7f4c8dfb7..856247c31 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -38,6 +38,7 @@ import { blockBodyOf, } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; +import { isStrictCode } from "./strict-mode.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -48,16 +49,22 @@ function declareCallReceiver( ): IrStmt { const receiver = lowerer.declareThis(DYN); const loc = locOf(node); + const bound: IrExpr = { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }; + // A sloppy-mode function's `this` is the global object when it is called + // with undefined or null. The function's own body decides its mode. + const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body); return { kind: "varDecl", localId: receiver.id, - init: { - kind: "libCall", - fn: generator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }, + init: sloppy + ? { kind: "libCall", fn: "dyn.sloppyReceiver", args: [bound], type: DYN, loc } + : bound, loc, }; } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a9e39b9ab..142c633c6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3530,6 +3530,9 @@ function buildLibFnSignatures() { "process.pid": { argTypes: [], result: F64 }, "dyn.this": { argTypes: [], result: DYN }, "dyn.generatorThis": { argTypes: [], result: DYN }, + /** A sloppy-mode function's `this` from its receiver: the global object + * for undefined and null, else the receiver. Never throws. */ + "dyn.sloppyReceiver": { argTypes: [DYN], result: DYN }, "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..8f7ac41ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8412-frozen-checked-records-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json new file mode 100644 index 000000000..6cf5a288c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8413-frozen-checked-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json new file mode 100644 index 000000000..348ce0ece --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8414-sloppy-this-global.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 0fafb706e..550769a8e 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1547,6 +1547,9 @@ void scr_dyn_obj_delete_symbol_field(ScrDyn *obj, ScrDyn *symbol); * object answers it; its symbol-keyed properties are the global object's * (scr_node_builtin.c). */ ScrDyn *scr_global_object(void); +/* The `this` of a sloppy-mode function called with the receiver `value` + * (borrowed), +1: the global object for undefined and null, else `value`. */ +ScrDyn *scr_dyn_sloppy_receiver(ScrDyn *value); /* The program's builtin-global lookup, a dyn function (borrowed); the first * installation stands. */ void scr_global_values_install(ScrDyn *lookup); diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 37efa8822..017d1f7cf 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -280,6 +280,11 @@ ScrDyn *scr_global_object(void) { return scr_global_box; } +ScrDyn *scr_dyn_sloppy_receiver(ScrDyn *value) { + if (!value || scr_dyn_is_nullish(value)) return scr_dyn_retain(scr_global_object()); + return scr_dyn_retain(value); +} + /* Installs the program's builtin-global lookup (borrowed): reads of * builtin names through the global object answer what their static * spellings read. The first installation stands; modules that read the diff --git a/tests/corpus/8412-frozen-checked-records-sloppy.cjs b/tests/corpus/8412-frozen-checked-records-sloppy.cjs new file mode 100644 index 000000000..f6edab231 --- /dev/null +++ b/tests/corpus/8412-frozen-checked-records-sloppy.cjs @@ -0,0 +1,37 @@ +// Untyped values frozen in sloppy-mode code and then held as typed records +// stay frozen: writes through typed parameters and a typed `this` leave them +// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value +// checked into the same record type stays writable. + +/** @typedef {{ count: number, label: string }} Counter */ + +/** @param {Counter} c @param {number} by */ +function bump(c, by) { + c.count += by; + c.label = c.label + "!"; + return c.count; +} + +/** @this {Counter} @param {number} by */ +function bumpThis(by) { + this.count += by; + this.label = "this"; + return this.count; +} + +/** @type {Counter} */ +const literal = Object.freeze({ count: 1, label: "a" }); +console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal)); + +/** @type {Counter} */ +const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}')); +console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed)); + +/** @type {Counter} */ +const open = JSON.parse('{"count":100,"label":"c"}'); +console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open)); + +/** @type {Counter[]} */ +const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }]; +for (const item of list) bump(item, 1); +console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1])); diff --git a/tests/corpus/8413-frozen-checked-records.ts b/tests/corpus/8413-frozen-checked-records.ts new file mode 100644 index 000000000..f31c4f0fa --- /dev/null +++ b/tests/corpus/8413-frozen-checked-records.ts @@ -0,0 +1,25 @@ +// An untyped value frozen and then held as a typed record stays frozen: +// writes to it in strict mode code throw Node's TypeError and leave it +// unchanged. An unfrozen value checked into the same record type stays +// writable. + +type Counter = { count: number; label: string }; + +function bump(c: Counter, by: number): string { + try { + c.count += by; + return "wrote " + c.count; + } catch (e) { + return e instanceof TypeError ? "TypeError" : "other"; + } +} + +const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter; +console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen)); + +const open = JSON.parse('{"count":10,"label":"b"}') as Counter; +console.log(bump(open, 2), open.count, Object.isFrozen(open)); + +const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); +const viaUnknown = raw as Counter; +console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); diff --git a/tests/corpus/8414-sloppy-this-global.cjs b/tests/corpus/8414-sloppy-this-global.cjs new file mode 100644 index 000000000..056b8d429 --- /dev/null +++ b/tests/corpus/8414-sloppy-this-global.cjs @@ -0,0 +1,46 @@ +// In sloppy-mode functions, `this` is the global object when the function is +// called with undefined or null as its receiver: by a plain call, by a +// detached method, and through call and apply. Strict functions and arrows +// keep the receiver they get. + +function receiver() { + return this; +} +function kind() { + return typeof this; +} +function strictReceiver() { + "use strict"; + return this; +} + +console.log(receiver() === globalThis, kind()); +console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis); +console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis); +console.log(strictReceiver() === undefined, strictReceiver.call(null) === null); + +const holder = { + name: "holder", + self() { + return this; + }, +}; +const detached = holder.self; +console.log(holder.self() === holder, detached() === globalThis); + +function outer() { + function inner() { + return this; + } + return inner() === globalThis; +} +console.log(outer(), outer.call({ name: "o" })); + +globalThis.sloppyMarker = 42; +function readMarker() { + return this.sloppyMarker; +} +console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 })); + +const arrow = () => this; +console.log(arrow() === module.exports, arrow.call(null) === module.exports); From f945efecee3541e8303ab2e9541ddbd2ff20a0c4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 15:55:31 -0500 Subject: [PATCH 13/22] Run JavaScript WritableStream, TransformStream and pipes natively In JavaScript sources, WritableStream with its default writer and controller, TransformStream with its controller, pipeTo and pipeThrough, and TextEncoderStream and TextDecoderStream join the native web stream classes, following Node 24's webstreams step by step: sinks and transformers, backpressure and the writers' ready and closed promises, abort with the controller's signal, the pipe's shared write requests and its abort signal, and UTF-8 encoding and decoding across chunks settle in Node's order. The streams take own properties, which a pipe does not use for its own reads and writes, and the global object answers the classes by computed name in programs that name them. A JavaScript class that extends one of these classes keeps the SC1090 refusal, and TextDecoderStream refuses encodings other than UTF-8. The node24-web-stream lifecycle, errors, queues, transforms, pipes, pipe-errors, pipe-abort, tee and encoding cases now match Node. --- .../builtin-apis/streams-and-readline.mdx | 13 +- .../src/generated/node-v24-compatibility.json | 6 +- .../generated/node-v24-internal.json | 12 +- internal/compatibility/static-support.json | 3 +- .../lowering/builtins/web-streams-dyn.ts | 36 + .../src/frontend/lowering/global-values.ts | 12 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + packages/compiler/src/ir/runtime-features.ts | 5 +- ...eb-stream-transform-pipes-untyped.mjs.json | 6 + .../web-stream-writable-untyped.mjs.json | 6 + packages/runtime/src/scr_dyn.h | 9 +- packages/runtime/src/scr_webstreams.c | 2752 ++++++++++++++++- .../web-stream-transform-pipes-untyped.mjs | 151 + tests/corpus/web-stream-writable-untyped.mjs | 144 + ...eb-stream-transform-pipes-untyped.mjs.json | 1 + .../web-stream-writable-untyped.mjs.json | 1 + tests/harness/npm.test.ts | 11 +- 17 files changed, 3114 insertions(+), 57 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-transform-pipes-untyped.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/web-stream-writable-untyped.mjs.json create mode 100644 tests/corpus/web-stream-transform-pipes-untyped.mjs create mode 100644 tests/corpus/web-stream-writable-untyped.mjs create mode 100644 tests/harness/corpus-costs/web-stream-transform-pipes-untyped.mjs.json create mode 100644 tests/harness/corpus-costs/web-stream-writable-untyped.mjs.json 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/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 1320c7774..aab91ce8b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3645,6 +3645,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(); diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 9a98cef01..a19351c22 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/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_dyn.h b/packages/runtime/src/scr_dyn.h index 0fafb706e..ab94282f1 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -244,7 +244,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. */ diff --git a/packages/runtime/src/scr_webstreams.c b/packages/runtime/src/scr_webstreams.c index e4b192b87..8ae438902 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,2054 @@ 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; + } + scr_promise_resolve_dyn(start_promise, 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) { @@ -2092,6 +4170,8 @@ static ScrDyn *ws_readable_get_reader(WsReadable *s, ScrDyn *options) { static ScrDyn *ws_readable_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; (void)self; (void)what; WsReadable *s = ptr; @@ -2129,10 +4209,73 @@ 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); } @@ -2140,6 +4283,8 @@ static ScrDyn *ws_readable_invoke(void *ptr, ScrDyn *self, const char *method, S 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 +4303,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}}; @@ -2212,6 +4356,8 @@ static ScrDyn *ws_reader_read(WsReader *r) { static ScrDyn *ws_reader_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; (void)self; (void)what; WsReader *r = ptr; @@ -2231,6 +4377,8 @@ static ScrDyn *ws_reader_invoke(void *ptr, ScrDyn *self, const char *method, Scr 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,7 +4386,6 @@ 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}; @@ -2250,6 +4397,8 @@ static const ScrDynHandleClass ws_reader_class = { static ScrDyn *ws_rcontroller_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; (void)self; (void)what; WsRController *c = ptr; @@ -2279,6 +4428,8 @@ static ScrDyn *ws_rcontroller_invoke(void *ptr, ScrDyn *self, const char *method 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,7 +4437,6 @@ 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}; @@ -2294,6 +4444,507 @@ static const ScrDynHandleClass ws_rcontroller_class = { "ReadableStreamDefaultController", 0, NULL, ws_rcontroller_accessors, NULL, ws_rcontroller_order, }; +/* WritableStream */ +static ScrDyn *ws_writable_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; + 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); +} + +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, +}; + +/* 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_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; + 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); +} + +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, +}; + +/* WritableStreamDefaultController */ +static ScrDyn *ws_wcontroller_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; + 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); +} + +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, +}; + +/* 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_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; + (void)ptr; + return ws_invoke_member(self, method, args, argc, what); +} + + +static const ScrDynHandleClass ws_transform_class = { + "TransformStream", 0, ws_transform_construct, ws_transform_accessors, NULL, NULL, +}; + +/* TransformStreamDefaultController */ +static ScrDyn *ws_tcontroller_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; + 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); +} + +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, +}; + +/* ── 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_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 ws_invoke_member(self, method, args, argc, what); +} + +static const ScrDynHandleClass ws_text_encoder_class = { + "TextEncoderStream", 0, ws_text_encoder_construct, ws_text_encoder_accessors, NULL, NULL, +}; + +static const ScrDynHandleClass ws_text_decoder_class = { + "TextDecoderStream", 0, ws_text_decoder_construct, ws_text_decoder_accessors, NULL, NULL, +}; + /* ── queuing strategies ────────────────────────────────────────────── */ typedef struct WsStrategy { @@ -2412,14 +5063,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 +5084,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 +5093,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 +5148,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/tests/corpus/web-stream-transform-pipes-untyped.mjs b/tests/corpus/web-stream-transform-pipes-untyped.mjs new file mode 100644 index 000000000..3bf171fd6 --- /dev/null +++ b/tests/corpus/web-stream-transform-pipes-untyped.mjs @@ -0,0 +1,151 @@ +// 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 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; })); +} + +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/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 a17ca231a..11347c8da 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -57,6 +57,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": { build: "SC2013" }, "misc-shims": { run: "SC1090" }, @@ -87,15 +88,6 @@ const KNOWN: Record = { "node24-url-mutation": { run: "SC1090" }, "node24-util-adapters": { run: "Object.create with this value is not supported yet" }, "node24-util-types": { run: "SC1090" }, - "node24-web-stream-encoding": { run: "SC2020" }, - "node24-web-stream-errors": { run: "SC1090" }, - "node24-web-stream-lifecycle": { run: "SC2020" }, - "node24-web-stream-pipe-abort": { run: "SC2020" }, - "node24-web-stream-pipe-errors": { run: "SC2020" }, - "node24-web-stream-pipes": { run: "SC2020" }, - "node24-web-stream-queues": { run: "SC2020" }, - "node24-web-stream-tee": { run: "SC1090" }, - "node24-web-stream-transforms": { run: "SC1090" }, "package-imports": { build: "SC2013" }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, @@ -103,6 +95,7 @@ const KNOWN: Record = { "util-shims": { run: "SC2020" }, // The package redeclares the var it exported: Node copies the first value. "var-export-redeclared": { run: "SC1090" }, + // `class Upper extends TransformStream`: extending a builtin class from JavaScript. "web-streams": { run: "SC1090" }, "workspace-copied": { build: "SC2013" }, "workspace-unclean": { build: "SC2013" }, From 039ca078ca354bc07fb190df8b6f4e830e83e246 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 16:54:44 -0500 Subject: [PATCH 14/22] Check unit records against one content snapshot per wave instead of per-unit copies Recording a unit copied the keys and values of every appendable table before the unit and compared them after it. Each appendable table now keeps one snapshot of its entries' content per recorded wave (JournalTable.contents): a unit is checked only on the entries its IR and new entries name, by key or by the helper name its value holds, and the wave's end checks the content and key order of every entry. The name scan keeps only the strings the tables are keyed by (names that start with % and union ids). ShapeRegistry.journalSizes counts its declared-order candidates and attached names as they are added. On tsc-ts the serial mode's overhead over plain lowering falls from 12.7 s to 10.4 s. --- .../frontend/lowering/unit-journal.test.ts | 25 ++- .../src/frontend/lowering/unit-journal.ts | 197 ++++++++++-------- packages/compiler/src/frontend/type-mapper.ts | 19 +- 3 files changed, 140 insertions(+), 101 deletions(-) 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 2dee0d62a..062d19973 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -323,16 +323,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, @@ -341,12 +341,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 @@ -401,6 +401,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); @@ -556,6 +557,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 }, ]); @@ -618,6 +620,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), @@ -745,10 +748,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. From a0198c80c6740a5651a204dddcaf157e016e9c07 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 17:18:53 -0500 Subject: [PATCH 15/22] Read virtual calls by their kind when the unwind ABI walks a body Without the node index, or for a function the index didn't record, eachNodeOfKinds passes every node of the body, statements too. The unwind ABI's virtual-call pass cast each node to an expression before testing its kind, and the native compiler checks that cast: with SCRIPTC_EMIT_FACTS_VERIFY=1, whose comparison walks every body, it stopped on the first statement. The kind test now narrows the node. --- packages/compiler/src/backend/llvm/unwind-abi.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 1c75a6b82..fdd0a2f18 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -107,12 +107,13 @@ export function unwindAbiFunctions( const anyClass = new Set(); const virtualCalls = nodes?.scan(VIRTUAL_CALL_KINDS); for (const fn of mod.functions) + // 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) => { - const e = node as 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); + 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 From 54aba6a60658d518594e2817076dfd4d6d75b746 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 13:27:37 -0700 Subject: [PATCH 16/22] Build the scr_map semantics test at -O0 The scr_map unit test compiles 23 runtime translation units with ASan/UBSan, and that build is essentially the whole test: 90s of clang against a 2.1s binary run. scr_hash.c alone takes 29s at -O1 because the sanitizer instruments its unrolled digest rounds; scr_json.c and scr_lib.c add 28s more. The test then normally ran within 20s of its 120s timeout, and whenever a lane shared the box it repeatedly timed out in both the plain and sanitized lanes. The link set cannot shrink: scr_map.c's dyn ops need scr_dyn_* from scr_json.c, scr_lib.c supplies the remaining date/math/number/perf symbols, and scr_lib.c needs scr_hash.c. The test asserts Map semantics under sanitizers, not codegen, and its cost checks are relative bounds, so build it at -O0: every assertion and sanitizer check still runs and the build drops to about 15s. Eight concurrent copies now take 20-22s each instead of 90s, with the timeout unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_map.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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", From 458fe7d54a4a59a3cb96a134b67de58f42ff7962 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 15:27:08 -0700 Subject: [PATCH 17/22] Give the three-library release and sanitized cases 1200s timeouts The test compiles the three npm package twice, dev and release, and both cases shared one 600s budget through test.each. Release compiles the whole library into one LLVM module that scriptc-llvm-codegen optimizes and codegens in a single process on a single core: 426s of the 477s total run in the native-archive phase alone. Across sandbox lanes release measured 331, 335, 339, 345, 348, 350, 358, 428, 450, 511, 521 and 581s, so whole- box speed varies about 1.7x and the slowest lanes sat within 20s of the budget; the sanitized dev case hit 600s twice. There is no regression, the work is fixed and single-threaded. Generate the two cases in a loop so each gets its own timeout: 1200_000ms for release and for dev under SCRIPTC_SAN, and the unchanged 600_000ms else. Test names are byte-identical to the .each %s expansion, so skipUnownedTest keeps assigning each case to the same shard. Co-Authored-By: Claude Opus 5.5 --- tests/harness/three-library.test.ts | 138 +++++++++++++++------------- 1 file changed, 74 insertions(+), 64 deletions(-) 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), + ); + } }); From fa046b8757ba46eded66e14d0999ea52149043a4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 17:32:28 -0500 Subject: [PATCH 18/22] Compile dev executables of more than 64 KiB in partitions A dev executable was one partition up to 1 MiB of LLVM text, so every edit compiled the whole program again. Unoptimized partitions are compiled on their own, with nothing imported across them, so dividing a program does not change its code's quality, and the helper reuses each partition an edit does not change from the partition cache. nativeProgramPartitions now takes whether the program is optimized. Dev executables are divided from 64 KiB of LLVM text, with at least 32 KiB per partition: four partitions up to 128 KiB, then at least 8 as before. Release and speed executables keep their partition and code generation counts. --- docs/content/docs/how-it-works.mdx | 2 +- .../src/backend/native-codegen-core.ts | 24 ++++++++++++++----- .../src/backend/native-codegen.test.ts | 8 +++++++ packages/compiler/src/index.ts | 4 +++- packages/compiler/src/native/compiler.ts | 4 +++- 5 files changed, 33 insertions(+), 9 deletions(-) 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/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/index.ts b/packages/compiler/src/index.ts index b002e2437..c41653565 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -784,7 +784,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, diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 19351a81a..fafae4f9d 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -386,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, From 4e98e5e9bdf65362b51ac6f5ffddd5b8da034112 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 17:53:47 -0500 Subject: [PATCH 19/22] Call web stream methods past own overrides, and await a transformer's thenable start A method value of a web stream class, such as a saved WritableStream.prototype.close, called on an instance whose own property of the same name wraps it, ran the own property again, so the wrapper recursed. The classes' method values now call the class's method directly (ScrDynHandleClass.method_invoke); member calls still look at own properties first. TransformStream resolved its start promise with a transformer start's result without adopting a thenable, so both sides started at once and a rejection was lost. The start promise now resolves as a promise's resolve function does (scr_dyn_promise_resolve_with): a thenable adopts through its resolve job, a throwing `then` read rejects, and the stream starts or errors when it settles, in Node's order. Lowerer.namesWebStreamClass, a cache made when first needed, is listed in Lowerer.unjournaled. --- .../compiler/src/frontend/lowering/lowerer.ts | 1 + packages/runtime/src/scr_async_dyn.c | 11 ++- packages/runtime/src/scr_dyn.h | 16 ++++ packages/runtime/src/scr_dyn_handle_class.c | 8 ++ packages/runtime/src/scr_json.c | 7 ++ packages/runtime/src/scr_webstreams.c | 82 ++++++++++--------- .../web-stream-transform-pipes-untyped.mjs | 63 +++++++++++++- 7 files changed, 147 insertions(+), 41 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index aab91ce8b..f532e11a2 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4259,6 +4259,7 @@ export class Lowerer { "createRequireBindingTargets", "createRequireElementTargets", "computedGlobalReads", + "namesWebStreamClass", "timeValueDateDecisions", "timeValueDateWrites", "dynamicOverloadAnswers", 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 ab94282f1..6ac123e58 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -2061,6 +2061,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 @@ -2087,6 +2093,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 @@ -2163,6 +2174,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_json.c b/packages/runtime/src/scr_json.c index 0b574b1fb..7de149143 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -7004,7 +7004,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_webstreams.c b/packages/runtime/src/scr_webstreams.c index 8ae438902..1ea9c754e 100644 --- a/packages/runtime/src/scr_webstreams.c +++ b/packages/runtime/src/scr_webstreams.c @@ -3520,7 +3520,10 @@ static ScrDyn *ws_transform_construct(ScrDyn *const *args, size_t argc) { ws_release(t); goto done; } - scr_promise_resolve_dyn(start_promise, value); + /* 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); } @@ -4128,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); @@ -4168,10 +4183,8 @@ 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) { - ScrDyn *own_result; - if (ws_own_invoke(ptr, self, method, args, argc, what, &own_result)) return own_result; (void)self; (void)what; WsReadable *s = ptr; @@ -4279,6 +4292,7 @@ static ScrDyn *ws_readable_invoke(void *ptr, ScrDyn *self, const char *method, S } 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; @@ -4310,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 */ @@ -4354,10 +4368,8 @@ 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) { - ScrDyn *own_result; - if (ws_own_invoke(ptr, self, method, args, argc, what, &own_result)) return own_result; (void)self; (void)what; WsReader *r = ptr; @@ -4373,6 +4385,7 @@ 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; @@ -4390,15 +4403,13 @@ static const ScrDynHandleMethod ws_reader_methods[] = { 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) { - ScrDyn *own_result; - if (ws_own_invoke(ptr, self, method, args, argc, what, &own_result)) return own_result; (void)self; (void)what; WsRController *c = ptr; @@ -4425,6 +4436,7 @@ 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); @@ -4441,14 +4453,12 @@ static const ScrDynHandleMethod ws_rcontroller_methods[] = { 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_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_writable_method(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; WsWritable *s = ptr; size_t len = strlen(method); if (ws_name(method, len, "abort")) { @@ -4470,6 +4480,7 @@ static ScrDyn *ws_writable_invoke(void *ptr, ScrDyn *self, const char *method, S } 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; @@ -4487,7 +4498,7 @@ static const ScrDynHandleMethod ws_writable_methods[] = { 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, + "WritableStream", 0, ws_writable_construct, ws_writable_accessors, NULL, ws_writable_order, ws_writable_method, }; /* WritableStreamDefaultWriter */ @@ -4507,10 +4518,8 @@ static ScrDyn *ws_writer_construct(ScrDyn *const *args, size_t argc) { static const char ws_writer_unbound[] = "Writer is not bound to a WritableStream"; -static ScrDyn *ws_writer_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_writer_method(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; WsWriter *w = ptr; size_t len = strlen(method); if (ws_name(method, len, "abort")) { @@ -4533,6 +4542,7 @@ static ScrDyn *ws_writer_invoke(void *ptr, ScrDyn *self, const char *method, Scr } 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; @@ -4560,14 +4570,12 @@ static const char *const ws_writer_order[] = {"closed", "desiredSize", "ready", "close", "releaseLock", "write", NULL}; static const ScrDynHandleClass ws_writer_class = { - "WritableStreamDefaultWriter", 1, ws_writer_construct, ws_writer_accessors, NULL, ws_writer_order, + "WritableStreamDefaultWriter", 1, ws_writer_construct, ws_writer_accessors, NULL, ws_writer_order, ws_writer_method, }; /* WritableStreamDefaultController */ -static ScrDyn *ws_wcontroller_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, +static ScrDyn *ws_wcontroller_method(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; WsWController *c = ptr; size_t len = strlen(method); if (ws_name(method, len, "error")) { @@ -4576,6 +4584,7 @@ static ScrDyn *ws_wcontroller_invoke(void *ptr, ScrDyn *self, const char *method } 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); @@ -4590,7 +4599,7 @@ static const ScrDynHandleMethod ws_wcontroller_methods[] = {{"error", 0, false, 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, + "WritableStreamDefaultController", 0, NULL, ws_wcontroller_accessors, NULL, ws_wcontroller_order, ws_wcontroller_method, }; /* TransformStream */ @@ -4603,24 +4612,21 @@ static ScrDyn *ws_transform_get(void *ptr, const char *key, size_t len) { return scr_dyn_handle_class_member(SCR_DYNH_WS_TRANSFORM, ptr, key, len); } -static ScrDyn *ws_transform_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_transform_method(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; (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, + "TransformStream", 0, ws_transform_construct, ws_transform_accessors, NULL, NULL, ws_transform_method, }; /* TransformStreamDefaultController */ -static ScrDyn *ws_tcontroller_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, +static ScrDyn *ws_tcontroller_method(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; WsTController *c = ptr; size_t len = strlen(method); if (ws_name(method, len, "enqueue")) { @@ -4637,6 +4643,7 @@ static ScrDyn *ws_tcontroller_invoke(void *ptr, ScrDyn *self, const char *method } 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; @@ -4654,7 +4661,7 @@ static const ScrDynHandleMethod ws_tcontroller_methods[] = { 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, + "TransformStreamDefaultController", 0, NULL, ws_tcontroller_accessors, NULL, ws_tcontroller_order, ws_tcontroller_method, }; /* ── TextEncoderStream and TextDecoderStream (webstreams/encoding.js) */ @@ -4930,19 +4937,18 @@ static ScrDyn *ws_text_get(void *ptr, const char *key, size_t len) { return scr_dyn_handle_class_member(tag, ptr, key, len); } -static ScrDyn *ws_text_invoke(void *ptr, ScrDyn *self, const char *method, ScrDyn *const *args, size_t argc, +static ScrDyn *ws_text_method(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 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, + "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, + "TextDecoderStream", 0, ws_text_decoder_construct, ws_text_decoder_accessors, NULL, NULL, ws_text_method, }; /* ── queuing strategies ────────────────────────────────────────────── */ diff --git a/tests/corpus/web-stream-transform-pipes-untyped.mjs b/tests/corpus/web-stream-transform-pipes-untyped.mjs index 3bf171fd6..b72fec111 100644 --- a/tests/corpus/web-stream-transform-pipes-untyped.mjs +++ b/tests/corpus/web-stream-transform-pipes-untyped.mjs @@ -1,7 +1,8 @@ // 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 encoder and +// 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"; @@ -127,6 +128,66 @@ out("prototype", Object.keys(TransformStream.prototype).join(","), Object.keys(T 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} */ ({}))), From 90471a180dd5177b6ca0ec123e0990b9dd7e4abf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 17:54:43 -0500 Subject: [PATCH 20/22] Read a proxy's integrity from its target, and give sloppy this the program's global object Records checked from a proxy in a program that freezes untyped values no longer fail on the proxy's integrity query: a proxy is frozen exactly when its target is. A sloppy-mode function called with undefined or null receives the global object that globalThis reads, so its builtin members such as console answer before anything else reads the global object. The new corpus programs are named instead of numbered and record their CI costs. --- packages/compiler/src/backend/llvm/dyn.ts | 4 +-- .../compiler/src/backend/llvm/lib-shared.ts | 1 - .../src/backend/llvm/reference-writes.ts | 1 - .../src/frontend/lowering/lower-functions.ts | 9 ++++++- .../frontend/lowering/lower-global-value.ts | 26 ++++++++++++++++--- .../compiler/src/ir/builtin-signatures.ts | 1 - ...412-frozen-checked-records-sloppy.cjs.json | 6 ----- .../8413-frozen-checked-records.ts.json | 6 ----- .../corpus/8414-sloppy-this-global.cjs.json | 6 ----- .../frozen-checked-records-sloppy.cjs.json | 6 +++++ .../corpus/frozen-checked-records.ts.json | 6 +++++ .../tests/corpus/sloppy-this-global.cjs.json | 6 +++++ packages/runtime/src/scr_dyn.h | 7 ++--- packages/runtime/src/scr_json.c | 5 ++++ packages/runtime/src/scr_node_builtin.c | 5 ---- ....cjs => frozen-checked-records-sloppy.cjs} | 0 ...d-records.ts => frozen-checked-records.ts} | 4 +++ ...this-global.cjs => sloppy-this-global.cjs} | 6 +++++ .../frozen-checked-records-sloppy.cjs.json | 1 + .../frozen-checked-records.ts.json | 1 + .../corpus-costs/sloppy-this-global.cjs.json | 1 + 21 files changed, 72 insertions(+), 36 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json rename tests/corpus/{8412-frozen-checked-records-sloppy.cjs => frozen-checked-records-sloppy.cjs} (100%) rename tests/corpus/{8413-frozen-checked-records.ts => frozen-checked-records.ts} (84%) rename tests/corpus/{8414-sloppy-this-global.cjs => sloppy-this-global.cjs} (84%) create mode 100644 tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json create mode 100644 tests/harness/corpus-costs/frozen-checked-records.ts.json create mode 100644 tests/harness/corpus-costs/sloppy-this-global.cjs.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index cf42d6a98..58bad60af 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2892,12 +2892,12 @@ export class LlDyn { // A record built from a frozen object is frozen too, as the object it // copies (frozen-records.ts makes such shapes freezable). if (shape.freezable) { - host.declare(`declare zeroext i1 @scr_dyn_is_frozen(ptr)`); + host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`); host.declare(`declare void @scr_record_freeze(ptr)`); const frozen = B.tmp(); const lFreeze = B.newLabel("dcr.freeze"); const lDone = B.newLabel("dcr.frozen"); - B.line(`${frozen} = call i1 @scr_dyn_is_frozen(ptr %d)`); + B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`); B.condBr(frozen, lFreeze, lDone); B.startBlock(lFreeze); B.line(`call void @scr_record_freeze(ptr %r0)`); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c4eacd7af..0271664ac 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1127,7 +1127,6 @@ export const LIB_FN_SYMS: Record = { "dyn.dataViewIs": "scr_dyn_data_view_is", "dyn.construct": "scr_dyn_construct", "dyn.this": "scr_dyn_this_get", - "dyn.sloppyReceiver": "scr_dyn_sloppy_receiver", "dyn.generatorThis": "scr_gen_receiver", "insp.dyn": "scr_insp_dyn", "insp.dynS": "scr_insp_dyn_s", diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index fedc5b4c3..a21f2f411 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -95,7 +95,6 @@ const INERT_LIB_CALLS = new Set([ // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", // A sloppy-mode function's receiver from it (a fresh reference). - "dyn.sloppyReceiver", "error.new", "error.nodeThrow", "dyn.typedRefIs", diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 856247c31..263cbcb5f 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -39,6 +39,7 @@ import { } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; import { isStrictCode } from "./strict-mode.js"; +import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -63,7 +64,13 @@ function declareCallReceiver( kind: "varDecl", localId: receiver.id, init: sloppy - ? { kind: "libCall", fn: "dyn.sloppyReceiver", args: [bound], type: DYN, loc } + ? { + kind: "nullish", + left: bound, + right: lowerSloppyGlobalReceiver(lowerer, node, loc), + type: DYN, + loc, + } : bound, loc, }; diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index e7ab6b0f0..53e538298 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; +import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -8,11 +8,29 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; * Known globals without a value implementation refuse at runtime; absent host * capabilities answer undefined. The checker supplies names, never values. */ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr { - const loc = locOf(expr); + return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr)); +} + +/** The global object a sloppy-mode function receives as `this` when it is + * called with undefined or null: the value `globalThis` reads. */ +export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(node), + false, + ); + return lowerGlobalValueOfType( + lowerer, + symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol), + loc, + ); +} + +function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr { // The temporal global is typed for the programs that name it (TS 7's // lib slice) but absent in Node 24: reads answer undefined. - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(expr)) + const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type)) .map((property) => ts.symbolName(property)) .filter((name) => name !== TEMPORAL_GLOBAL); return { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 142c633c6..874aa9736 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3532,7 +3532,6 @@ function buildLibFnSignatures() { "dyn.generatorThis": { argTypes: [], result: DYN }, /** A sloppy-mode function's `this` from its receiver: the global object * for undefined and null, else the receiver. Never throws. */ - "dyn.sloppyReceiver": { argTypes: [DYN], result: DYN }, "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json deleted file mode 100644 index 8f7ac41ef..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-frozen-checked-records-sloppy.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/8412-frozen-checked-records-sloppy.cjs" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json deleted file mode 100644 index 6cf5a288c..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8413-frozen-checked-records.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/8413-frozen-checked-records.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json deleted file mode 100644 index 348ce0ece..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8414-sloppy-this-global.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/8414-sloppy-this-global.cjs" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..7f5478af9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json new file mode 100644 index 000000000..a0771e0cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json new file mode 100644 index 000000000..0bc118bd0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 550769a8e..e754c27ec 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1087,6 +1087,10 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value); * the programs that link it. */ ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_frozen(const ScrDyn *value); +/* Whether a record checked from `value` is frozen: `value` is. A proxy is + * frozen exactly when its target is (the proxy invariants), so the check + * reads the target. */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value); ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); @@ -1547,9 +1551,6 @@ void scr_dyn_obj_delete_symbol_field(ScrDyn *obj, ScrDyn *symbol); * object answers it; its symbol-keyed properties are the global object's * (scr_node_builtin.c). */ ScrDyn *scr_global_object(void); -/* The `this` of a sloppy-mode function called with the receiver `value` - * (borrowed), +1: the global object for undefined and null, else `value`. */ -ScrDyn *scr_dyn_sloppy_receiver(ScrDyn *value); /* The program's builtin-global lookup, a dyn function (borrowed); the first * installation stands. */ void scr_global_values_install(ScrDyn *lookup); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 0b574b1fb..4423926a3 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -19173,6 +19173,11 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { return false; } +bool scr_dyn_checked_record_frozen(const ScrDyn *value) { + while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; + return value && scr_dyn_is_frozen(value); +} + double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes); diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 017d1f7cf..37efa8822 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -280,11 +280,6 @@ ScrDyn *scr_global_object(void) { return scr_global_box; } -ScrDyn *scr_dyn_sloppy_receiver(ScrDyn *value) { - if (!value || scr_dyn_is_nullish(value)) return scr_dyn_retain(scr_global_object()); - return scr_dyn_retain(value); -} - /* Installs the program's builtin-global lookup (borrowed): reads of * builtin names through the global object answer what their static * spellings read. The first installation stands; modules that read the diff --git a/tests/corpus/8412-frozen-checked-records-sloppy.cjs b/tests/corpus/frozen-checked-records-sloppy.cjs similarity index 100% rename from tests/corpus/8412-frozen-checked-records-sloppy.cjs rename to tests/corpus/frozen-checked-records-sloppy.cjs diff --git a/tests/corpus/8413-frozen-checked-records.ts b/tests/corpus/frozen-checked-records.ts similarity index 84% rename from tests/corpus/8413-frozen-checked-records.ts rename to tests/corpus/frozen-checked-records.ts index f31c4f0fa..1c205079e 100644 --- a/tests/corpus/8413-frozen-checked-records.ts +++ b/tests/corpus/frozen-checked-records.ts @@ -23,3 +23,7 @@ console.log(bump(open, 2), open.count, Object.isFrozen(open)); const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); const viaUnknown = raw as Counter; console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); + +// A proxy checked into the record type stays writable. +const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter; +console.log(bump(proxied, 2), proxied.count); diff --git a/tests/corpus/8414-sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs similarity index 84% rename from tests/corpus/8414-sloppy-this-global.cjs rename to tests/corpus/sloppy-this-global.cjs index 056b8d429..1fef1859a 100644 --- a/tests/corpus/8414-sloppy-this-global.cjs +++ b/tests/corpus/sloppy-this-global.cjs @@ -3,6 +3,12 @@ // detached method, and through call and apply. Strict functions and arrows // keep the receiver they get. +// Before anything else reads the global object. +function readConsole() { + return typeof this.console.log === "function" && this.console === console; +} +console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); + function receiver() { return this; } diff --git a/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..6289d836d --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1 @@ +{ "plain": 3590, "san": 12400 } diff --git a/tests/harness/corpus-costs/frozen-checked-records.ts.json b/tests/harness/corpus-costs/frozen-checked-records.ts.json new file mode 100644 index 000000000..1086c8461 --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records.ts.json @@ -0,0 +1 @@ +{ "plain": 2610, "san": 12260 } diff --git a/tests/harness/corpus-costs/sloppy-this-global.cjs.json b/tests/harness/corpus-costs/sloppy-this-global.cjs.json new file mode 100644 index 000000000..66f469d88 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global.cjs.json @@ -0,0 +1 @@ +{ "plain": 3030, "san": 3970 } From 08fbecb7e7fa934b6d87017ebba63e16c55e8e6c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 17:55:44 -0500 Subject: [PATCH 21/22] Excuse only reproduced differences as nondeterministic, and read vendor archive symbols A program is nondeterministic only when every artifact that differs also differs between two baseline builds; a deterministic difference in another artifact still fails the gate. The runtime-only check now reads the symbols of changed vendor archives as well as runtime objects. --- scripts/zero-cost-compare.mjs | 17 ++++++++++++++--- scripts/zero-cost-gate.mjs | 7 ++++--- tests/harness/zero-cost-gate.test.ts | 26 ++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index c8f2d756d..f3284f195 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -523,6 +523,14 @@ export function runtimePackDiff(baseline, candidate) { }; } +/** 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. */ @@ -638,7 +646,9 @@ export function programLines(entry) { * 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". Neither fails the gate unless `strict` is set. */ + * "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, @@ -646,7 +656,7 @@ export function programVerdict({ allowed, required = false, runtimeOnly = false, - nondeterministic = false, + nondeterministic = [], strict = false, }) { if (!baseline.ok || !candidate.ok) { @@ -659,7 +669,8 @@ export function programVerdict({ if (allowed) return { verdict: "allowed", fails: false }; if (runtimeOnly && differing.every(([kind]) => kind === "exe")) return { verdict: "runtime-only", fails: strict }; - if (nondeterministic) return { verdict: "nondeterministic", fails: strict }; + if (differing.every(([kind]) => nondeterministic.includes(kind))) + return { verdict: "nondeterministic", fails: strict }; return { verdict: "differs", fails: true }; } diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index 3931a8c69..2c79495b2 100644 --- a/scripts/zero-cost-gate.mjs +++ b/scripts/zero-cost-gate.mjs @@ -45,6 +45,7 @@ import { DYNAMIC_WORKLOADS, definedFunctionNames, dynamicTierSymbols, + linkedRuntimeFiles, llvmFunctionHashes, median, nmOutput, @@ -502,7 +503,7 @@ function runtimeChanges() { const objects = [...diff.changed, ...diff.added, ...diff.removed].filter((path) => path.endsWith(".o"), ); - for (const path of objects) + for (const path of linkedRuntimeFiles(diff)) for (const root of [trees.baseline, trees.candidate]) { const file = join(packDir(root), path); if (!existsSync(file)) continue; @@ -808,7 +809,7 @@ await pool( nondeterministic, strict: values.strict, }); - let { verdict, fails } = judge(false); + 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. @@ -822,7 +823,7 @@ await pool( ); if (again.dir && !values.keep) rmSync(again.dir, { recursive: true, force: true }); } - if (recheck?.length) ({ verdict, fails } = judge(true)); + if (recheck?.length) ({ verdict, fails } = judge(recheck)); } const entry = { id: program.id, diff --git a/tests/harness/zero-cost-gate.test.ts b/tests/harness/zero-cost-gate.test.ts index c7fc56a8f..77da7e5b9 100644 --- a/tests/harness/zero-cost-gate.test.ts +++ b/tests/harness/zero-cost-gate.test.ts @@ -10,6 +10,7 @@ import { confinedToRuntime, definedFunctionNames, dynamicTierSymbols, + linkedRuntimeFiles, llvmFunctionHashes, mergeReports, nmOutput, @@ -222,14 +223,35 @@ test("the zero-cost gate classifies runtime-only and nondeterministic difference }); // A changed runtime cannot explain different LLVM. expect(verdict(llvmToo, { runtimeOnly: true })).toEqual({ verdict: "differs", fails: true }); - expect(verdict(llvmToo, { nondeterministic: true })).toEqual({ + expect(verdict(llvmToo, { nondeterministic: ["llvm", "exe"] })).toEqual({ verdict: "nondeterministic", fails: false, }); - expect(verdict(llvmToo, { nondeterministic: true, strict: true })).toEqual({ + 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", () => { From 919b79b62a372fb2bd62567d19887350e990e8f0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 19:20:37 -0500 Subject: [PATCH 22/22] Revert "Keep frozen checked records frozen, and give sloppy-mode this the global object" --- .../builtin-apis/object-freezing.mdx | 1 - .../language-features/function-values.mdx | 2 - packages/compiler/src/backend/llvm/dyn.ts | 15 ------ .../src/backend/llvm/reference-writes.ts | 1 - .../src/frontend/lowering/frozen-records.ts | 29 ++++------- .../src/frontend/lowering/lower-functions.ts | 28 +++------- .../frontend/lowering/lower-global-value.ts | 26 ++-------- .../compiler/src/ir/builtin-signatures.ts | 2 - .../frozen-checked-records-sloppy.cjs.json | 6 --- .../corpus/frozen-checked-records.ts.json | 6 --- .../tests/corpus/sloppy-this-global.cjs.json | 6 --- packages/runtime/src/scr_dyn.h | 4 -- packages/runtime/src/scr_json.c | 5 -- .../corpus/frozen-checked-records-sloppy.cjs | 37 ------------- tests/corpus/frozen-checked-records.ts | 29 ----------- tests/corpus/sloppy-this-global.cjs | 52 ------------------- .../frozen-checked-records-sloppy.cjs.json | 1 - .../frozen-checked-records.ts.json | 1 - .../corpus-costs/sloppy-this-global.cjs.json | 1 - 19 files changed, 20 insertions(+), 232 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json delete mode 100644 tests/corpus/frozen-checked-records-sloppy.cjs delete mode 100644 tests/corpus/frozen-checked-records.ts delete mode 100644 tests/corpus/sloppy-this-global.cjs delete mode 100644 tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json delete mode 100644 tests/harness/corpus-costs/frozen-checked-records.ts.json delete mode 100644 tests/harness/corpus-costs/sloppy-this-global.cjs.json diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 80281bb2d..030b03b4b 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,7 +5,6 @@ A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. - A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way. The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 32af01186..7083f865d 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -5,5 +5,3 @@ - **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them. A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element. Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction. - A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node. - Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there). diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 58bad60af..dfeffdecb 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2889,21 +2889,6 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${ek})`); }); } - // A record built from a frozen object is frozen too, as the object it - // copies (frozen-records.ts makes such shapes freezable). - if (shape.freezable) { - host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`); - host.declare(`declare void @scr_record_freeze(ptr)`); - const frozen = B.tmp(); - const lFreeze = B.newLabel("dcr.freeze"); - const lDone = B.newLabel("dcr.frozen"); - B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`); - B.condBr(frozen, lFreeze, lDone); - B.startBlock(lFreeze); - B.line(`call void @scr_record_freeze(ptr %r0)`); - B.br(lDone); - B.startBlock(lDone); - } B.terminate(`ret ptr %r0`); break; } diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index c352b5eb4..56379a443 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -94,7 +94,6 @@ const INERT_LIB_CALLS = new Set([ "dyn.compare", // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", - // A sloppy-mode function's receiver from it (a fresh reference). "error.new", "error.nodeThrow", "dyn.typedRefIs", diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 75056b3cc..3700b57ff 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -121,8 +121,6 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; - const checked: IrType[] = []; - let freezesUntyped = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -132,28 +130,19 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true; - else if (e.kind === "dynFrom") live.push(e.value.type); - else if (e.kind === "dynCheck") checked.push(e.type); + } else if (e.kind === "dynFrom") live.push(e.value.type); return true; }, stmt: () => true, }); - if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; - const stack: IrType[] = []; - // A record checked from an untyped value copies it, frozen when the value - // is (a fresh literal frozen in a JavaScript file included), so the shapes - // a check builds are freezable in a program that freezes untyped values. - if (freezesUntyped) stack.push(...checked); - if (lowerer.untypedIntegrity) { - // An untyped reference to a class instance, or to a record or array - // boxed live, reaches the values its fields and elements hold as further - // live references. Class instances also cross into untyped code through - // compiled callbacks, so every class is a root. - stack.push(...live); - for (const info of lowerer.classes.values()) - for (const field of info.def.fields) stack.push(field.type); - } + if (!lowerer.untypedIntegrity) return { shapes, arrays }; + // An untyped reference to a class instance, or to a record or array + // boxed live, reaches the values its fields and elements hold as further + // live references. Class instances also cross into untyped code through + // compiled callbacks, so every class is a root. + const stack = [...live]; + for (const info of lowerer.classes.values()) + for (const field of info.def.fields) stack.push(field.type); const seen = new Set(); while (stack.length > 0) { const type = stack.pop()!; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 263cbcb5f..7f4c8dfb7 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -38,8 +38,6 @@ import { blockBodyOf, } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; -import { isStrictCode } from "./strict-mode.js"; -import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -50,28 +48,16 @@ function declareCallReceiver( ): IrStmt { const receiver = lowerer.declareThis(DYN); const loc = locOf(node); - const bound: IrExpr = { - kind: "libCall", - fn: generator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }; - // A sloppy-mode function's `this` is the global object when it is called - // with undefined or null. The function's own body decides its mode. - const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body); return { kind: "varDecl", localId: receiver.id, - init: sloppy - ? { - kind: "nullish", - left: bound, - right: lowerSloppyGlobalReceiver(lowerer, node, loc), - type: DYN, - loc, - } - : bound, + init: { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index 53e538298..e7ab6b0f0 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js"; +import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -8,29 +8,11 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; * Known globals without a value implementation refuse at runtime; absent host * capabilities answer undefined. The checker supplies names, never values. */ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr { - return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr)); -} - -/** The global object a sloppy-mode function receives as `this` when it is - * called with undefined or null: the value `globalThis` reads. */ -export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr { - const symbol = lowerer.checker.resolveName( - "globalThis", - ts.SymbolFlags.Value, - ts.getSourceFileOfNode(node), - false, - ); - return lowerGlobalValueOfType( - lowerer, - symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol), - loc, - ); -} - -function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr { + const loc = locOf(expr); // The temporal global is typed for the programs that name it (TS 7's // lib slice) but absent in Node 24: reads answer undefined. - const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type)) + const names = lowerer.checker + .getPropertiesOfType(lowerer.typeOf(expr)) .map((property) => ts.symbolName(property)) .filter((name) => name !== TEMPORAL_GLOBAL); return { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index d3f07b894..06ddb5007 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3539,8 +3539,6 @@ function buildLibFnSignatures() { "process.pid": { argTypes: [], result: F64 }, "dyn.this": { argTypes: [], result: DYN }, "dyn.generatorThis": { argTypes: [], result: DYN }, - /** A sloppy-mode function's `this` from its receiver: the global object - * for undefined and null, else the receiver. Never throws. */ "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json deleted file mode 100644 index 7f5478af9..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/frozen-checked-records-sloppy.cjs" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json deleted file mode 100644 index a0771e0cf..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/frozen-checked-records.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json deleted file mode 100644 index 0bc118bd0..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/sloppy-this-global.cjs" - ], - "diags": [] -} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 91fe7c74e..6ac123e58 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1094,10 +1094,6 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value); * the programs that link it. */ ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_frozen(const ScrDyn *value); -/* Whether a record checked from `value` is frozen: `value` is. A proxy is - * frozen exactly when its target is (the proxy invariants), so the check - * reads the target. */ -bool scr_dyn_checked_record_frozen(const ScrDyn *value); ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d9ff30dd5..7de149143 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -19180,11 +19180,6 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { return false; } -bool scr_dyn_checked_record_frozen(const ScrDyn *value) { - while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; - return value && scr_dyn_is_frozen(value); -} - double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes); diff --git a/tests/corpus/frozen-checked-records-sloppy.cjs b/tests/corpus/frozen-checked-records-sloppy.cjs deleted file mode 100644 index f6edab231..000000000 --- a/tests/corpus/frozen-checked-records-sloppy.cjs +++ /dev/null @@ -1,37 +0,0 @@ -// Untyped values frozen in sloppy-mode code and then held as typed records -// stay frozen: writes through typed parameters and a typed `this` leave them -// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value -// checked into the same record type stays writable. - -/** @typedef {{ count: number, label: string }} Counter */ - -/** @param {Counter} c @param {number} by */ -function bump(c, by) { - c.count += by; - c.label = c.label + "!"; - return c.count; -} - -/** @this {Counter} @param {number} by */ -function bumpThis(by) { - this.count += by; - this.label = "this"; - return this.count; -} - -/** @type {Counter} */ -const literal = Object.freeze({ count: 1, label: "a" }); -console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal)); - -/** @type {Counter} */ -const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}')); -console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed)); - -/** @type {Counter} */ -const open = JSON.parse('{"count":100,"label":"c"}'); -console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open)); - -/** @type {Counter[]} */ -const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }]; -for (const item of list) bump(item, 1); -console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1])); diff --git a/tests/corpus/frozen-checked-records.ts b/tests/corpus/frozen-checked-records.ts deleted file mode 100644 index 1c205079e..000000000 --- a/tests/corpus/frozen-checked-records.ts +++ /dev/null @@ -1,29 +0,0 @@ -// An untyped value frozen and then held as a typed record stays frozen: -// writes to it in strict mode code throw Node's TypeError and leave it -// unchanged. An unfrozen value checked into the same record type stays -// writable. - -type Counter = { count: number; label: string }; - -function bump(c: Counter, by: number): string { - try { - c.count += by; - return "wrote " + c.count; - } catch (e) { - return e instanceof TypeError ? "TypeError" : "other"; - } -} - -const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter; -console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen)); - -const open = JSON.parse('{"count":10,"label":"b"}') as Counter; -console.log(bump(open, 2), open.count, Object.isFrozen(open)); - -const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); -const viaUnknown = raw as Counter; -console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); - -// A proxy checked into the record type stays writable. -const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter; -console.log(bump(proxied, 2), proxied.count); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs deleted file mode 100644 index 1fef1859a..000000000 --- a/tests/corpus/sloppy-this-global.cjs +++ /dev/null @@ -1,52 +0,0 @@ -// In sloppy-mode functions, `this` is the global object when the function is -// called with undefined or null as its receiver: by a plain call, by a -// detached method, and through call and apply. Strict functions and arrows -// keep the receiver they get. - -// Before anything else reads the global object. -function readConsole() { - return typeof this.console.log === "function" && this.console === console; -} -console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); - -function receiver() { - return this; -} -function kind() { - return typeof this; -} -function strictReceiver() { - "use strict"; - return this; -} - -console.log(receiver() === globalThis, kind()); -console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis); -console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis); -console.log(strictReceiver() === undefined, strictReceiver.call(null) === null); - -const holder = { - name: "holder", - self() { - return this; - }, -}; -const detached = holder.self; -console.log(holder.self() === holder, detached() === globalThis); - -function outer() { - function inner() { - return this; - } - return inner() === globalThis; -} -console.log(outer(), outer.call({ name: "o" })); - -globalThis.sloppyMarker = 42; -function readMarker() { - return this.sloppyMarker; -} -console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 })); - -const arrow = () => this; -console.log(arrow() === module.exports, arrow.call(null) === module.exports); diff --git a/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json deleted file mode 100644 index 6289d836d..000000000 --- a/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json +++ /dev/null @@ -1 +0,0 @@ -{ "plain": 3590, "san": 12400 } diff --git a/tests/harness/corpus-costs/frozen-checked-records.ts.json b/tests/harness/corpus-costs/frozen-checked-records.ts.json deleted file mode 100644 index 1086c8461..000000000 --- a/tests/harness/corpus-costs/frozen-checked-records.ts.json +++ /dev/null @@ -1 +0,0 @@ -{ "plain": 2610, "san": 12260 } diff --git a/tests/harness/corpus-costs/sloppy-this-global.cjs.json b/tests/harness/corpus-costs/sloppy-this-global.cjs.json deleted file mode 100644 index 66f469d88..000000000 --- a/tests/harness/corpus-costs/sloppy-this-global.cjs.json +++ /dev/null @@ -1 +0,0 @@ -{ "plain": 3030, "san": 3970 }