diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index a9f03b857..0e085c56f 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -1,5 +1,10 @@ - **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. + An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners. + An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert. + Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported. + Untyped `newListener` and `removeListener` listeners receive `undefined` in place of a listener registered with typed parameters. + Reading EventEmitter methods as properties through an untyped value (`typeof emitter.listeners`) answers `undefined`. `events.once` accepts EventEmitter and stream instances with a literal event name and an optional object-literal `{ signal }`; a stream's `data` event, other options values, and EventTarget emitters are unsupported. Reading `defaultMaxListeners` through a CommonJS destructured or member binding (`const { defaultMaxListeners } = require("events")`) is unsupported. diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 38f0155c0..99b78ded5 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,4 +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. Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. + 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. + A write to a read-only property of a checked dictionary or a function throws a `TypeError` whose message omits Node's `'x' of object '#'` (or `of function '…'`) suffix. A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing. diff --git a/docs/content/limitations/webassembly-modules.mdx b/docs/content/limitations/webassembly-modules.mdx index 43fa55e48..b74f2094d 100644 --- a/docs/content/limitations/webassembly-modules.mdx +++ b/docs/content/limitations/webassembly-modules.mdx @@ -5,7 +5,8 @@ Bytes the build did not see, including bytes produced at run time, reject with a `scriptc coverage` lists the modules a build finds, with their size, whether they validate, and the WebAssembly proposals they use. Translated modules can use the WebAssembly 2.0 feature set: the MVP plus sign-extension operators, non-trapping float-to-int conversions, bulk memory, multi-value, mutable globals, reference types, and extended constant expressions. -Modules that use SIMD, threads, exception handling, tail calls, typed function references or GC, memory64, or multiple memories are not translated, so loading them rejects with the `CompileError` above; packages that fall back to a scalar build when that happens, such as undici and es-module-lexer, take their fallback. +Modules that use SIMD, threads, exception handling, tail calls, typed function references or GC, memory64, or multiple memories are not translated, so loading them rejects with a `CompileError` that names the proposal the module uses, and `WebAssembly.validate` answers `false` for them; packages that fall back to a scalar build when that happens, such as undici and es-module-lexer, take their fallback. +Invalid modules the build finds reject with a `CompileError` that includes the validation error. Supported: `WebAssembly.compile`, `instantiate` (with bytes or a `Module`), `validate`, `new WebAssembly.Module`, `new WebAssembly.Instance`, `new WebAssembly.Memory`, `new WebAssembly.Table`, `new WebAssembly.Global`, `WebAssembly.Module.imports`, `exports` and `customSections`, and `instanceof` for every class; the error classes `CompileError`, `LinkError` and `RuntimeError`, constructed with or without `new`; `globalThis.WebAssembly` and its `compile`, `instantiate` and `validate` functions as values. Instances expose exported functions, memories (`buffer`, `grow`), tables (`get`, `set`, `grow`, `length`) and globals (`value`, `valueOf`). diff --git a/packages/cli/test/frontend-process.test.ts b/packages/cli/test/frontend-process.test.ts new file mode 100644 index 000000000..0e0bbf45b --- /dev/null +++ b/packages/cli/test/frontend-process.test.ts @@ -0,0 +1,260 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js"; + +// The first optimized executable build of an invocation prepares its input in a frontend process +// (prepareExecutableInputInChild in the compiler's index.ts) when the compiler runs from its built +// files, as the CLI's bootstrap does. Later builds of the invocation, and builds with +// SCRIPTC_FRONTEND_PROCESS=0, prepare it in the compiler's own process. +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../../.."); +const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js"); +const compilerApi = pathToFileURL(join(repoRoot, "packages/compiler/dist/index.js")).href; +const helperTarget = precompiledRuntimePackTarget(); + +interface Build { + code: number; + stdout: string; + stderr: string; +} + +async function build( + entry: string, + outPath: string, + cacheRoot: string, + inProcess: boolean, + extra: { args?: string[]; env?: NodeJS.ProcessEnv } = {}, +): Promise { + const env: NodeJS.ProcessEnv = { + ...process.env, + SCRIPTC_CACHE_DIR: cacheRoot, + SCRIPTC_TIMING: "1", + ...extra.env, + }; + delete env["SCRIPTC_NO_CACHE"]; + if (inProcess) env["SCRIPTC_FRONTEND_PROCESS"] = "0"; + else delete env["SCRIPTC_FRONTEND_PROCESS"]; + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + [bootstrap, "build", entry, "-o", outPath, ...(extra.args ?? [])], + { env, maxBuffer: 8 * 1024 * 1024 }, + ); + return { code: 0, stdout, stderr }; + } catch (error) { + const failed = error as { code?: number; stdout?: string; stderr?: string }; + return { code: failed.code ?? 1, stdout: failed.stdout ?? "", stderr: failed.stderr ?? "" }; + } +} + +/** The build's output without its timing lines, whose times differ between runs. */ +const withoutTiming = (text: string): string => + text + .split("\n") + .filter((line) => !line.startsWith("scriptc timing") && !line.startsWith("scriptc lowering")) + .join("\n"); + +test.skipIf(helperTarget === null)( + "the frontend process builds the executable, diagnostics and cache entries the in-process build does", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-")); + try { + const src = join(dir, "src"); + await mkdir(src); + await writeFile( + join(src, "shapes.ts"), + "export const area = (w: number, h: number): number => w * h;\n", + ); + await writeFile( + join(src, "main.ts"), + 'import { area } from "./shapes.js";\nconsole.log(area(3, 4), [1, 2, 3].map((x) => x * 2).join(","));\n', + ); + const entry = join(src, "main.ts"); + const out = (name: string): string => join(dir, name, "program"); + await mkdir(join(dir, "child")); + await mkdir(join(dir, "inline")); + + const child = await build(entry, out("child"), join(dir, "cache-child"), false); + const inline = await build(entry, out("inline"), join(dir, "cache-inline"), true); + expect(child.code).toBe(0); + expect(inline.code).toBe(0); + expect(child.stderr).toContain('"phase":"frontend-process"'); + expect(inline.stderr).not.toContain('"phase":"frontend-process"'); + expect(await readFile(out("child"))).toEqual(await readFile(out("inline"))); + expect(await readFile(join(dir, "child", "main.ll"), "utf8")).toBe( + await readFile(join(dir, "inline", "main.ll"), "utf8"), + ); + expect((await execFileAsync(out("child"))).stdout).toBe("12 2,4,6\n"); + // The request and result files are removed. + expect( + (await readdir(join(dir, "child"))).filter((name) => name.includes("frontend")), + ).toEqual([]); + + // A repeat finds the executable the frontend process's build published. An edit rebuilds in + // the compiler's own process. + const repeat = await build(entry, out("child"), join(dir, "cache-child"), false); + expect(repeat.code).toBe(0); + expect(repeat.stderr).not.toContain("scriptc lowering"); + expect(repeat.stderr).not.toContain('"phase":"frontend-process"'); + await writeFile( + join(src, "shapes.ts"), + "export const area = (w: number, h: number): number => w * h + 1;\n", + ); + const edited = await build(entry, out("child"), join(dir, "cache-child"), false); + expect(edited.code).toBe(0); + expect(edited.stderr).toContain("scriptc lowering"); + expect(edited.stderr).not.toContain('"phase":"frontend-process"'); + expect((await execFileAsync(out("child"))).stdout).toBe("13 2,4,6\n"); + + // Without the executable cache entries (as for a program whose frontend inputs cannot be + // cached), the rebuild still runs in the compiler's own process: the first build recorded + // its native program object. + await rm(join(dir, "cache-child", "early-exe"), { recursive: true, force: true }); + await rm(join(dir, "cache-child", "early-exe-route"), { recursive: true, force: true }); + const uncached = await build(entry, out("child"), join(dir, "cache-child"), false); + expect(uncached.code).toBe(0); + expect(uncached.stderr).toContain("scriptc lowering"); + expect(uncached.stderr).not.toContain('"phase":"frontend-process"'); + expect((await execFileAsync(out("child"))).stdout).toBe("13 2,4,6\n"); + + // A program the frontend rejects reports the same diagnostics either way. The first build of + // the invocation in an empty cache runs the frontend process. + await writeFile(join(src, "main.ts"), 'const n: number = "not a number";\nconsole.log(n);\n'); + const childError = await build(entry, out("child"), join(dir, "cache-child-error"), false); + const inlineError = await build(entry, out("inline"), join(dir, "cache-inline"), true); + expect(childError.stderr).toContain('"phase":"frontend-process"'); + expect(childError.code).not.toBe(0); + expect(childError.code).toBe(inlineError.code); + expect(withoutTiming(childError.stderr)).toBe(withoutTiming(inlineError.stderr)); + expect(withoutTiming(childError.stderr)).toContain("not a number"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); + +test.skipIf(helperTarget === null)( + "a compile called from evaluated code runs its frontend process once", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-eval-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, 'console.log("from eval");\n'); + const { stdout, stderr } = await execFileAsync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { compile } from ${JSON.stringify(compilerApi)}; + const result = await compile(process.argv[1], { outDir: process.argv[2], outPath: process.argv[3] }); + console.log("compiled", result.ok);`, + entry, + dir, + join(dir, "program"), + ], + { + env: { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache"), SCRIPTC_TIMING: "1" }, + maxBuffer: 8 * 1024 * 1024, + }, + ); + expect(stdout).toBe("compiled true\n"); + expect(stderr).toContain('"phase":"frontend-process"'); + expect((await execFileAsync(join(dir, "program"))).stdout).toBe("from eval\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); + +test.skipIf(helperTarget === null)( + "a build with --provenance-sources compiles the attested sources in the frontend process", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-provenance-")); + try { + const fixture = join(repoRoot, "tests/fixtures/provenance"); + const entry = join(fixture, "cases/greet/main.ts"); + const extra = { + args: ["--provenance-sources"], + env: { SCRIPTC_PROVENANCE_MANIFEST: join(fixture, "manifest.json") }, + }; + await mkdir(join(dir, "child")); + await mkdir(join(dir, "inline")); + const child = await build( + entry, + join(dir, "child", "greet"), + join(dir, "cache"), + false, + extra, + ); + const inline = await build( + entry, + join(dir, "inline", "greet"), + join(dir, "cache"), + true, + extra, + ); + expect(child.code).toBe(0); + expect(inline.code).toBe(0); + expect(child.stderr).toContain('"phase":"frontend-process"'); + const childLlvm = await readFile(join(dir, "child", "main.ll"), "utf8"); + expect(childLlvm).toBe(await readFile(join(dir, "inline", "main.ll"), "utf8")); + // Without the flag, the program compiles the published package instead. + await mkdir(join(dir, "published")); + const published = await build( + entry, + join(dir, "published", "greet"), + join(dir, "cache"), + true, + ); + expect(published.code).toBe(0); + expect(await readFile(join(dir, "published", "main.ll"), "utf8")).not.toBe(childLlvm); + expect((await execFileAsync(join(dir, "child", "greet"))).stdout).toBe( + "hello, world\nHELLO, COMPILER!\nhello, chain!\n", + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); + +test.skipIf(helperTarget === null)( + "concurrent first builds of entries with the same name in one output directory keep their own LLVM", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-stem-")); + try { + const out = join(dir, "out"); + await mkdir(out); + const entries = ["first", "second"].map((name) => join(dir, name, "main.ts")); + for (const [index, entry] of entries.entries()) { + await mkdir(join(entry, "..")); + // Different programs with the same stem write the same public main.ll. + const lines = Array.from({ length: 200 }, (_, line) => `console.log("${index}-${line}");`); + await writeFile(entry, `${lines.join("\n")}\n`); + } + const builds = await Promise.all( + entries.map((entry, index) => + build(entry, join(out, `program-${index}`), join(dir, `cache-${index}`), false), + ), + ); + for (const [index, result] of builds.entries()) { + expect(result.code).toBe(0); + expect(result.stderr).toContain('"phase":"frontend-process"'); + const { stdout } = await execFileAsync(join(out, `program-${index}`)); + expect(stdout.split("\n")[0]).toBe(`${index}-0`); + } + // The frontend process's private copies of the LLVM text are removed. + expect((await readdir(out)).filter((name) => name.includes("frontend"))).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index fb107ef99..329f066fc 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2128,6 +2128,10 @@ declare module "fs/promises" { options: import("node:fs").GlobOptionsWithFileTypes, ): AsyncIterableIterator; export function rm(path: string): Promise; + export function rm( + path: string, + options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number }, + ): Promise; export function stat(path: string): Promise; export function statfs( path: string | Uint8Array | URL, diff --git a/packages/compiler/native/host.c b/packages/compiler/native/host.c index 75c2dcbaf..6aa1b2a28 100644 --- a/packages/compiler/native/host.c +++ b/packages/compiler/native/host.c @@ -44,3 +44,15 @@ uint8_t scriptc_native_private_directory(const uint8_t *bytes, size_t size, uint return ok; #endif } + +/* The runtime's full sweep after a phase's data died (scr_rc.h). */ +void scr_collect_cycles_phase(void); + +/* Reclaims the cycles a finished compiler phase leaves behind. The + * frontend's syntax trees and program state refer to each other, so + * reference counting frees almost none of them when the frontend is + * disposed, and the collector's schedule alone may not run a full pass + * before the build ends. */ +void scriptc_native_collect_cycles(void) { + scr_collect_cycles_phase(); +} diff --git a/packages/compiler/native/host.ffi.json b/packages/compiler/native/host.ffi.json index f2a738054..d7e0e92aa 100644 --- a/packages/compiler/native/host.ffi.json +++ b/packages/compiler/native/host.ffi.json @@ -1,6 +1,7 @@ { "ffi_format": 6, "functions": [ - { "name": "compilerNativePrivateDirectory", "symbol": "scriptc_native_private_directory", "params": ["string", "bool"], "returns": "bool" } + { "name": "compilerNativePrivateDirectory", "symbol": "scriptc_native_private_directory", "params": ["string", "bool"], "returns": "bool" }, + { "name": "compilerNativeCollectCycles", "symbol": "scriptc_native_collect_cycles", "params": [], "returns": "void" } ] } diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index c41b88067..aa624579f 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -1,3 +1,4 @@ +import type { DynamicFieldStores } from "../ir/int32-slots.js"; import type { IrModule, IrType } from "../ir/ir.js"; import { DYN, @@ -34,8 +35,11 @@ import { computeCycleMutability, fieldKey } from "./cycle-mutability.js"; * subclass and retain touches the cycle header, so header presence must be * uniform across an extends tree): a unit is cycle-capable iff ANY member * is — every backend uses the same grouping. */ -export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } { - const { shapes, unions } = analyzeCycles(mod, false); +export function computeTraced( + mod: IrModule, + stores?: DynamicFieldStores, +): { shapes: Set; unions: Set } { + const { shapes, unions } = analyzeCycles(mod, false, stores); return { shapes, unions }; } @@ -88,6 +92,7 @@ export function explainCycle(mod: IrModule, key: string): CycleExplanation | nul function analyzeCycles( mod: IrModule, labels: boolean, + stores?: DynamicFieldStores, ): { shapes: Set; unions: Set; @@ -120,7 +125,7 @@ function analyzeCycles( const lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []); const programLifetime = (className: string): boolean => lifetimeRoots.size > 0 && lifetimeRoots.has(rootOf(className)); - const mutability = computeCycleMutability(mod, unitOfClass); + const mutability = computeCycleMutability(mod, unitOfClass, stores); const shapeDefs = [ ...classes.map((c) => ({ key: `object:${c.name}`, diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index 79993fdff..78daa6204 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -1,6 +1,6 @@ import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js"; import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES, typeKey } from "../ir/ir.js"; -import { dynamicFieldStores } from "../ir/int32-slots.js"; +import { dynamicFieldStores, type DynamicFieldStores } from "../ir/int32-slots.js"; import { everyExprChild, everyStmtChild, everyTypeChild } from "../ir/traverse.js"; /** Which shape fields can be written after their object escapes. @@ -72,6 +72,7 @@ export function fieldKey(unit: string, field: string): string { export function computeCycleMutability( mod: IrModule, unitOfClass: (className: string) => string, + stores?: DynamicFieldStores, ): CycleMutability { const result: CycleMutability = { all: false, @@ -100,7 +101,7 @@ export function computeCycleMutability( // intrinsics other than publish (which only marks objects immortal), the // intern-table operations (which store published values) and ThreadSlots // reads stay conservative. - const dynStores = dynamicFieldStores(mod); + const dynStores = stores ?? dynamicFieldStores(mod); const dynamicStores = dynStores.all || dynStores.names.size > 0; result.emptyPropertyBags = !dynamicStores; const boundaryWrites = (e: IrExpr): boolean => diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index c0b8985d0..0aff57b86 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -31,7 +31,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { ...(features.handleProps ? { handleProps: true } : {}), ...(features.dynForAwait ? { dynForAwait: true } : {}), ...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}), - ...(features.dynBufferNum ? { dynBufferNum: true } : {}), + ...(features.dynBuffer ? { dynBuffer: true } : {}), ...(features.builtinValues ? { builtinValues: true } : {}), events: features.processEvents, emitter: features.emitter, diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts index 949aec75e..6c37731e3 100644 --- a/packages/compiler/src/backend/immutability.ts +++ b/packages/compiler/src/backend/immutability.ts @@ -1,5 +1,6 @@ /* Static immutability facts the backend uses to drop run-time immutability * checks (see computeConstructionFacts). Pure functions of the IR module. */ +import type { DynamicFieldStores } from "../ir/int32-slots.js"; import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; import { @@ -44,7 +45,10 @@ export class ImmutableFields { } } -export function computeImmutableFields(mod: IrModule): ImmutableFields | null { +export function computeImmutableFields( + mod: IrModule, + stores?: DynamicFieldStores, +): ImmutableFields | null { if (mod.lib !== undefined) return null; const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const)); const roots = new Map(); @@ -58,7 +62,7 @@ export function computeImmutableFields(mod: IrModule): ImmutableFields | null { return cur; }; const unitOfClass = (name: string): string => `object:${rootOf(name)}`; - const mutability = computeCycleMutability(mod, unitOfClass); + const mutability = computeCycleMutability(mod, unitOfClass, stores); return mutability.all ? null : new ImmutableFields(mutability, unitOfClass); } diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 8f6dbb221..805198df1 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -33,6 +33,7 @@ export async function compilePackedLibrary( assert: options.assert ?? false, inspect: options.inspect ?? false, dynInvoke: options.dynInvoke ?? false, + ...(options.dynBuffer === true ? { dynBuffer: true } : {}), ...(options.dynTier === true ? { dynTier: true } : {}), dc: false, dynAsync: false, diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index c1fc5998d..c28a63e62 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -269,8 +269,9 @@ export function cycTypeDescriptor(name: string, trace: string, free: string, kin * expression) in `%o`, aborting on OOM, with the RC-audit allocation note * on the out-of-line path. With `cyc`, the object carries a collector * header (scr_cyc_alloc's contract). `stored` is the number of leading - * object bytes the caller stores unconditionally right after (at least the - * refcount word), which a recycled block need not clear. Labels use the + * object bytes the caller stores unconditionally right after (the refcount + * word, or the 4-byte header of a compact class instance), which a recycled + * block need not clear; it is a multiple of 4 and at most `size`. Labels use the * `sa.` prefix; the lines end in an open block. */ export function emitObjectAlloc( @@ -350,7 +351,7 @@ function allocLines( ` store ptr %sa.next, ptr %sa.fp`, ` %sa.z = getelementptr inbounds i8, ptr %sa.b, i64 ${keep}`, ` %sa.zn = sub i64 %sa.n, ${keep}`, - ` call void @llvm.memset.p0.i64(ptr align 8 %sa.z, i8 0, i64 %sa.zn, i1 false)`, + ` call void @llvm.memset.p0.i64(ptr align ${keep % 8 === 0 ? 8 : 4} %sa.z, i8 0, i64 %sa.zn, i1 false)`, ` br label %sa.fast`, `sa.bump: ; fresh reservation memory is already zero`, ` %sa.c1 = add i64 %sa.c, 1`, diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 8a2a4659a..5daaf4300 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -1,12 +1,19 @@ import { expect, test } from "vitest"; import { DYN, F64, STRING, VOID, type IrModule } from "../../ir/ir.js"; -import { buildClassGraph, classMembershipIntervals, emitClassMembershipHelper } from "./classes.js"; +import { + buildClassGraph, + classIdLoadLines, + classMembershipIntervals, + emitClassMembershipHelper, + type LlClassMeta, +} from "./classes.js"; import { emitLlvmModule } from "./emitter.js"; import { mangleClassNew, mangleClassReleaseDirect, mangleClassRelease, mangleClassRetain, + mangleClassStruct, mangleClassTrace, mangleRecordRelease, } from "../mangle.js"; @@ -129,18 +136,19 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRetain(cls)), cls).toMatch(/entry:\n {2}ret ptr %o\n}/); expect(body(mangleClassRelease(cls)), cls).toMatch(/entry:\n {2}ret void\n}/); // new: no collector header (no descriptor, no scr_cyc_alloc) and the - // program-lifetime count 2^31 under the class id (Node's preorder number - // 0 + 1, Leaf's 1 + 1), not SIZE_MAX (which means published). + // 32-bit header: the tag 0x70000000 and the class id (Node's preorder + // number 0 + 1, Leaf's 1 + 1), without a count. const nw = body(mangleClassNew(cls)); const id = cls === "Node" ? 1 : 2; - expect(nw, cls).toContain(`store i64 ${id * 2 ** 32 + 2 ** 31}, ptr %o.vt`); - expect(nw, cls).not.toContain("store i64 -1, ptr %o"); + expect(nw, cls).toContain(`store i32 ${0x70000000 + id}, ptr %o.vt`); + expect(nw, cls).not.toContain("store i64"); expect(nw, cls).not.toContain("scr_cyc_alloc"); expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); // The RC audit (sanitize builds) learns that the program keeps // program-lifetime objects; other builds emit nothing for it. expect(nw, cls).toContain("call void @scr_obj_lifetime_note(ptr %o)"); } + expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, ptr }`); const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); expect(plain).not.toContain("scr_obj_lifetime_note"); // The mortal Owner keeps its header, counts and possible-root step. @@ -149,6 +157,84 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp"); }); +test("ProgramLifetime classes pack their fields after a 32-bit header", () => { + const node = { kind: "object" as const, className: "Node" }; + const leaf = { kind: "object" as const, className: "Leaf" }; + const BOOL = { kind: "bool" as const }; + const nodeFields = [ + { name: "parent", type: node }, + { name: "flag", type: BOOL }, + { name: "weight", type: F64 }, + ]; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Node", fields: nodeFields, methods: [], loc }, + { + name: "Leaf", + base: "Node", + fields: [...nodeFields, { name: "next", type: leaf }, { name: "mark", type: BOOL }], + methods: [], + loc, + }, + ], + contracts: { programLifetime: ["Node"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + // Fields go widest first, each at the first offset that fits it. Node's + // weight (an int32 slot: nothing stores a fraction) takes the word the + // header shares (24 bytes, not 32); Leaf keeps Node's offsets and puts its + // bool next to Node's (32 bytes, not 48). + expect(llvm).toContain(`%${mangleClassStruct("Node")} = type { i32, i32, ptr, i8 }`); + expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, i32, ptr, i8, i8, ptr }`); + // Without the contract, the layout is the declared one. + const mortal = emitLlvmModule({ ...mod, contracts: {} }, { pointerBits: 64 }); + expect(mortal).toContain(`%${mangleClassStruct("Leaf")} = type { i64, ptr, i8, i32, ptr, i8 }`); +}); + +test("a recycled block is cleared from the end of a ProgramLifetime instance's 32-bit header", () => { + const BOOL = { kind: "bool" as const }; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Marker", fields: [], methods: [], loc }, + { name: "Toggle", fields: [{ name: "on", type: BOOL }], methods: [], loc }, + ], + contracts: { programLifetime: ["Marker", "Toggle"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + for (const name of ["Marker", "Toggle"]) { + const start = llvm.indexOf(`define internal ptr @${mangleClassNew(name)}(`); + const body = llvm.slice(start, llvm.indexOf("\n}\n", start)); + // The constructor stores the 4-byte header itself; a 4-byte instance + // (Marker) clears nothing, and Toggle's field at offset 4 is cleared. + expect(body).toContain("%sa.z = getelementptr inbounds i8, ptr %sa.b, i64 4"); + expect(body).toContain("%sa.zn = sub i64 %sa.n, 4"); + expect(body).toContain("call void @llvm.memset.p0.i64(ptr align 4 %sa.z,"); + } +}); + +test("invariant class id reads of compact instances leave out the published bit", () => { + const group = ", !invariant.group !{}"; + const compact = (post: number) => ({ compact: true, root: { post } }) as unknown as LlClassMeta; + // Publication sets the header word's top bit; ids below 2^16 are read + // from the low 16 bits, which never change after construction. + expect(classIdLoadLines(compact(40), "%o", "%id", group)).toEqual([ + " %id.h = load i16, ptr %o, !invariant.group !{}", + " %id = zext i16 %id.h to i32", + ]); + // Wider ids, and reads without the metadata, mask the whole word. + const masked = [" %id.w = load i32, ptr %o", " %id = and i32 %id.w, 268435455"]; + expect(classIdLoadLines(compact(0xffff), "%o", "%id", group)).toEqual(masked); + expect(classIdLoadLines(compact(40), "%o", "%id", "")).toEqual(masked); +}); + test("published ProgramLifetime instances use the published mark every other object uses", () => { const node = { kind: "object" as const, className: "Node" }; const leaf = { kind: "object" as const, className: "Leaf" }; @@ -212,19 +298,25 @@ test("published ProgramLifetime instances use the published mark every other obj contracts: { programLifetime: ["Node"] }, }; const llvm = emitLlvmModule(mod, { pointerBits: 64 }); - // The walker marks each instance like a mortal object (SIZE_MAX, logged so - // a refusal restores the program-lifetime count); untraced, so it never - // leaves a collector buffer. + // The walker marks each instance by the top bit of its 32-bit header, + // logging the word so a refusal restores it; untraced, so it never leaves + // a collector buffer. expect(llvm).toContain("call void @scr_publish(ptr"); - // Kind 4 (an object) | 0x100 (the rc word keeps a class id: the mark - // writes only the count). - expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 260\)/); - // The frozen-object guard of the store tests the count against UINT32_MAX: - // an unpublished instance (2^31) stays writable. + // Kind 4 (an object) | 0x200 (the 32-bit header: the mark sets its top + // bit). + expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 516\)/); + const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); + expect(plain).toContain("%mpub = or i32 %mrc, -2147483648"); + // Pushing a reference skips one published before: a set top bit. + expect(llvm).toMatch(/%immortal0 = icmp slt i32 %rc0, 0/); + // The walker dispatches on the class id in the header's low 28 bits. + expect(llvm).toContain("%id = and i32 %idw, 268435455"); + // The frozen-object guard of the store tests the top bit: an unpublished + // instance stays writable. const store = llvm.split(/^define /m).find((fn) => fn.includes("call void @scr_throw_published_field(")) ?? ""; - expect(store).toMatch(/icmp eq i32 %\S+, -1/); + expect(store).toMatch(/icmp slt i32 %\S+, 0/); expect(store).toContain("call void @scr_publish(ptr"); // 32-bit targets use 2^31. const llvm32 = emitLlvmModule(mod, { pointerBits: 32 }); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 5ad29430d..03ee8caa1 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -32,6 +32,7 @@ import { } from "../mangle.js"; import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js"; import { llvmCommentText } from "./common.js"; +import { INLINE_UNION_FIELD } from "./inline-unions.js"; import { NullableRefFields } from "./nullable-fields.js"; import type { Int32Slots } from "../../ir/int32-slots.js"; import { @@ -88,6 +89,15 @@ export class LlClassMeta { * bits, and the hierarchy's vtables are one array indexed by the id * (mangleVtArray). */ idInRc = false; + /** A ProgramLifetime class with the 32-bit header (assignCompactLayouts): + * the i32 at offset 0 is compactHeaderWord, with the class id in place of + * the rc word's high half, and the fields are packed after it. */ + compact = false; + /** Compact classes: the struct's elements by offset (the header, field + * -1, then the fields by their index in def.fields). */ + compactElems: { offset: number; size: number; align: number; type: string; field: number }[] = []; + /** Compact classes: each field's GEP index, by its index in def.fields. */ + compactSlots: number[] = []; constructor(readonly def: IrClassDef) { this.root = this; @@ -371,6 +381,10 @@ export function classSlotWordIndex(meta: LlClassMeta): number { /** The GEP index where a class's own fields start: after the header words * and the slot word. */ export function classFieldBase(meta: LlClassMeta): number { + if (meta.compact) + throw new InternalCompilerError( + `llvm emitter bug: compact class ${meta.def.name} packs its fields`, + ); return headerEnd(meta) + (meta.threadSlot ? 1 : 0); } @@ -387,7 +401,12 @@ export function classFieldIndex(meta: LlClassMeta, field: string): { index: numb throw new InternalCompilerError( `llvm emitter bug: unknown field ${field} on class ${meta.def.name}`, ); - return { index: classFieldBase(meta) + idx, type: meta.def.fields[idx]!.type }; + return { index: classFieldSlot(meta, idx), type: meta.def.fields[idx]!.type }; +} + +/** The GEP index of the field at `idx` in `meta.def.fields`. */ +export function classFieldSlot(meta: LlClassMeta, idx: number): number { + return meta.compact ? meta.compactSlots[idx]! : classFieldBase(meta) + idx; } /** Lines that release the ThreadSlots values of the object `%o` before it @@ -472,8 +491,7 @@ export function classPreLines( ): string[] { if (meta.idInRc) return [ - ` ${pre}.idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, - ` ${pre}.id = load i32, ptr ${pre}.idp`, + ...classIdLoadLines(meta, obj, `${pre}.id`, ""), ` ${pre}.n = add i32 ${pre}.id, -1 ; the class id is the preorder number + 1`, ` ${pre} = zext i32 ${pre}.n to ${sizeType}`, ]; @@ -499,14 +517,39 @@ export function classIdVtLines( const vtt = `%${mangleVtStruct(root.def.name)}`; const n = root.post - root.pre + 1; return [ - ` ${t}idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, - ` ${t}id = load i32, ptr ${t}idp${group}`, + ...classIdLoadLines(meta, `${obj}`, `${t}id`, group), ` ${t}ix = sub i32 ${t}id, ${root.pre + 1}`, ` ${t}ix64 = zext i32 ${t}ix to i64`, ` ${vt} = getelementptr inbounds [${n} x ${vtt}], ptr @${mangleVtArray(root.def.name)}, i64 0, i64 ${t}ix64`, ]; } +/** Lines that load the class id of the LlClassMeta.idInRc instance `obj` + * into the i32 `id`: the high half of the rc word, or the low 28 bits of a + * compact instance's header word (compactHeaderWord). `group` is the + * metadata of the load (`, !invariant.group !{}` for a load LLVM may treat + * as invariant), and it only goes on bytes that never change: publication + * sets the top bit of a compact header word, so a compact instance's id is + * then read as the word's low 16 bits, which hold the whole id when the + * hierarchy's ids are below 2^16, and otherwise as the masked word without + * the metadata. */ +export function classIdLoadLines( + meta: LlClassMeta, + obj: string, + id: string, + group: string, +): string[] { + if (meta.compact) { + if (group !== "" && meta.root.post + 1 <= 0xffff) + return [` ${id}.h = load i16, ptr ${obj}${group}`, ` ${id} = zext i16 ${id}.h to i32`]; + return [` ${id}.w = load i32, ptr ${obj}`, ` ${id} = and i32 ${id}.w, ${COMPACT_ID_MASK}`]; + } + return [ + ` ${id}p = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, + ` ${id} = load i32, ptr ${id}p${group}`, + ]; +} + /** The byte offset of the class id in the rc word of an instance that * keeps it there (LlClassMeta.idInRc): the high half of the little-endian * 64-bit word. The count is the i32 at offset 0. */ @@ -522,6 +565,134 @@ export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string { return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString(); } +/** The 32-bit header of a compact class instance (LlClassMeta.compact): + * COMPACT_HEADER_TAG, the class id (classIdRcWord's preorder number + 1, 0 + * outside a hierarchy) in the low 28 bits, and COMPACT_PUBLISHED once + * publication marks the object. No other object's count reaches + * 0x70000000, so the runtime reads a word of 0xf0000000 and up as published + * for every object (scr_rc.h SCR_RC_PUBLISHED). */ +export const COMPACT_HEADER_TAG = 0x70000000; +export const COMPACT_PUBLISHED = 0x80000000; +export const COMPACT_ID_MASK = 0x0fffffff; + +/** The leading bytes of a new instance that its allocation function stores + * right after allocating it (emitObjectAlloc's `stored`), which a recycled + * block therefore need not clear: a compact instance's 4-byte header word + * (its fields start at offset 4), the 8-byte rc word, or the rc word and + * the vtable word of a hierarchy member that keeps its class in a vtable. */ +export function storedHeaderBytes(meta: LlClassMeta): number { + if (meta.compact) return 4; + return meta.hierarchy && !meta.idInRc ? 16 : 8; +} + +/** The header word a compact instance is created with. */ +export function compactHeaderWord(meta: LlClassMeta): number { + return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0); +} + +/** Byte size and alignment of a class field's LLVM storage type. */ +function fieldStorage(type: string): { size: number; align: number } { + switch (type) { + case "i8": + return { size: 1, align: 1 }; + case "i32": + return { size: 4, align: 4 }; + case "ptr": + case "double": + return { size: 8, align: 8 }; + case INLINE_UNION_FIELD: + return { size: 16, align: 8 }; + default: + throw new InternalCompilerError(`llvm emitter bug: class field storage ${type}`); + } +} + +/** The LLVM storage type of each of a class's flattened fields. */ +export function classFieldTypes(host: ShapeHost, cls: IrClassDef, int32?: Int32Slots): string[] { + // Whole-program int32 fields (int32-slots.ts) store the exact i32. + return cls.fields.map((f) => + int32?.isField(cls.name, f.name) + ? "i32" + : llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)), + ); +} + +/** Gives the classes `compact` admits (whole hierarchies: the test is the + * root's) the 32-bit header and packed fields. A class keeps its base's + * field offsets, and places each of its own fields, widest first, at the + * first offset after the header that fits it: in a hole another field's + * alignment left, in the base's tail padding, or at the end. So 32-bit and + * smaller fields share 8-byte words. The struct lists the header and the + * fields by offset, which LLVM's layout of the type reproduces (checked). */ +export function assignCompactLayouts( + metaMap: ReadonlyMap, + compact: (root: LlClassMeta) => boolean, + fieldTypes: (cls: IrClassDef) => string[], +): void { + const byPre = [...metaMap.values()].sort((a, b) => a.pre - b.pre); + for (const meta of byPre) { + if (meta.def.runtime === true || !compact(meta.root)) continue; + const base = meta.base; + const inherited = base?.compactElems ?? [ + { offset: 0, size: 4, align: 4, type: "i32", field: -1 }, + ]; + const baseCount = base?.def.fields.length ?? 0; + if (base !== null && (!base.compact || !sameFieldPrefix(base.def, meta.def))) + throw new InternalCompilerError( + `llvm emitter bug: compact class ${meta.def.name} does not extend its base's layout`, + ); + const elems = inherited.map((e) => ({ ...e })); + const types = fieldTypes(meta.def); + const own = types + .map((type, field) => ({ type, field, ...fieldStorage(type) })) + .slice(baseCount) + .sort((a, b) => b.size - a.size || a.field - b.field); + for (const f of own) { + const sorted = [...elems].sort((a, b) => a.offset - b.offset); + let at = -1; + let cursor = 0; + for (const e of sorted) { + const start = alignTo(cursor, f.align); + if (start + f.size <= e.offset) { + at = start; + break; + } + cursor = Math.max(cursor, e.offset + e.size); + } + if (at < 0) at = alignTo(cursor, f.align); + elems.push({ offset: at, size: f.size, align: f.align, type: f.type, field: f.field }); + } + elems.sort((a, b) => a.offset - b.offset); + // LLVM places each element at the next offset its alignment allows. + let end = 0; + for (const e of elems) { + if (alignTo(end, e.align) !== e.offset) + throw new InternalCompilerError( + `llvm emitter bug: compact layout of ${meta.def.name} is not LLVM's`, + ); + end = e.offset + e.size; + } + const slots = new Array(types.length).fill(-1); + elems.forEach((e, index) => { + if (e.field >= 0) slots[e.field] = index; + }); + meta.compact = true; + meta.compactElems = elems; + meta.compactSlots = slots; + } +} + +function alignTo(n: number, align: number): number { + return Math.ceil(n / align) * align; +} + +function sameFieldPrefix(base: IrClassDef, cls: IrClassDef): boolean { + return ( + base.fields.length <= cls.fields.length && + base.fields.every((f, i) => cls.fields[i]!.name === f.name) + ); +} + function undefFieldInits( host: ClassHost, meta: LlClassMeta, @@ -589,12 +760,17 @@ export function emitClassShapes( for (const cls of emitted) { const meta = metaMap.get(cls.name)!; - // Whole-program int32 fields (int32-slots.ts) store the exact i32. - const fieldTys = cls.fields.map((f) => - int32?.isField(cls.name, f.name) - ? "i32" - : llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)), - ); + if (meta.compact) { + typeDefs.push( + `%${mangleClassStruct(cls.name)} = type { ${meta.compactElems.map((e) => e.type).join(", ")} } ` + + `; class ${cls.name} (32-bit header) { ${meta.compactElems + .slice(1) + .map((e) => llvmCommentText(cls.fields[e.field]!.name)) + .join("; ")} }`, + ); + continue; + } + const fieldTys = classFieldTypes(host, cls, int32); // Subclasses of a runtime base embed its remaining prefix (the state // and display-name slots) between the vtable word and the field list; // stream subclasses add the stream-state pointer — upcasts to @@ -718,7 +894,7 @@ export function emitClassShapes( const traced = host.tracedShapes.has(`object:${cls.name}`); const runtimePrefix = runtimePrefixOf(meta); const isStreamRooted = streamRooted(meta); - const fieldIndex = (i: number): number => classFieldBase(meta) + i; + const fieldIndex = (i: number): number => classFieldSlot(meta, i); // Teardown, trace and gcFree see each slot's storage type: a nullable // class field holds the instance pointer (NULL for the unit arm), and // every class retain/release/visit entry point is NULL-tolerant. @@ -851,30 +1027,41 @@ export function emitClassShapes( ``, ); // Direct release (the vtable entry): the class's own teardown. The - // caller already handled NULL/immortal. - const reld: string[] = [ - `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`, - `entry:`, - ` %rc = load ${count}, ptr %o`, - ` %n = sub ${count} %rc, 1`, - ` store ${count} %n, ptr %o`, - ` %dead = icmp eq ${count} %n, 0`, - ` br i1 %dead, label %free, label %${traced ? "root" : "done"}`, - `free:`, - ]; - if (traced) reld.push(...cycleDeadLines(host, "teardown")); - if (bounded) { - const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`; - const destroyBody: string[] = []; - teardown(destroyBody); - defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``); - host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); - reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`); - } else teardown(reld); - reld.push(` br label %done`); - if (traced) reld.push(`root:`, ...cycleRootLines(host, "done")); - reld.push(`done:`, ` ret void`, `}`, ``); - defs.push(...reld); + // caller already handled NULL/immortal. A compact instance has no + // count: nothing releases it. + if (meta.compact) { + defs.push( + `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name} (immortal)`, + `entry:`, + ` ret void`, + `}`, + ``, + ); + } else { + const reld: string[] = [ + `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`, + `entry:`, + ` %rc = load ${count}, ptr %o`, + ` %n = sub ${count} %rc, 1`, + ` store ${count} %n, ptr %o`, + ` %dead = icmp eq ${count} %n, 0`, + ` br i1 %dead, label %free, label %${traced ? "root" : "done"}`, + `free:`, + ]; + if (traced) reld.push(...cycleDeadLines(host, "teardown")); + if (bounded) { + const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`; + const destroyBody: string[] = []; + teardown(destroyBody); + defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``); + host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); + reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`); + } else teardown(reld); + reld.push(` br label %done`); + if (traced) reld.push(`root:`, ...cycleRootLines(host, "done")); + reld.push(`done:`, ` ret void`, `}`, ``); + defs.push(...reld); + } } else { const freeBody: string[] = []; teardown(freeBody); @@ -927,10 +1114,22 @@ export function emitClassShapes( host, sizeOf, traced && !lifetime ? { type: cycType } : null, - meta.hierarchy && !meta.idInRc ? 16 : 8, + storedHeaderBytes(meta), ), ); - if (meta.idInRc) { + if (meta.compact && !meta.hierarchy) + nw.push(` store i32 ${compactHeaderWord(meta)}, ptr %o ; no count (compactHeaderWord)`); + else if (meta.compact) { + // The header word: the class id and no count (compactHeaderWord). + // Only publication writes it again, setting COMPACT_PUBLISHED in the + // top byte, so the class id reads that carry !invariant.group read + // the low 16 bits only (classIdLoadLines). + host.declare(`declare ptr @llvm.launder.invariant.group.p0(ptr)`); + nw.push( + ` %o.vt = call ptr @llvm.launder.invariant.group.p0(ptr %o)`, + ` store i32 ${compactHeaderWord(meta)}, ptr %o.vt ; class ${llvmCommentText(cls.name)} is ${meta.pre}`, + ); + } else if (meta.idInRc) { // The class id in the high half of the rc word, the count in the low // half (classIdRcWord). It never changes while the object lives: // retains, releases and the runtime write only the low half. diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index cbaded527..6911ef98b 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -3245,7 +3245,8 @@ export class LlDyn { /* ── the dyn ToString pair (dynToStrHelper, ported) ────────────────── */ /** Node's String() over a dyn value — sc_ds (+1 result) over the - * recursive sc_ds_buf walker. Borrowed operand; never throws. */ + * recursive sc_ds_buf walker. Borrowed operand; an exception that an + * object's toString or valueOf throws stays pending. */ dynToStrHelper(): string { if (this.dynToStrFn) return this.dynToStrFn; const name = "sc_ds"; @@ -3348,9 +3349,9 @@ export class LlDyn { B.startBlock(labels.get(DYN_KIND.ARR)!); { // Array.prototype.toString: join(",") — null/undefined ELEMENTS - // print empty (unlike top level), nested arrays flatten. + // print empty (unlike top level), nested arrays flatten. The length + // is read once, as join reads it. const n = this.lenOf(B, "%d"); - const items = this.itemsOf(B, "%d"); this.i64Loop(B, "ds.ar", n, (i, brNext) => { const nz = B.tmp(); B.line(`${nz} = icmp ugt ${this.S} ${i}, 0`); @@ -3361,7 +3362,22 @@ export class LlDyn { B.line(`call void @scr_jb_putc(ptr %b, i8 44)`); B.br(lel); B.startBlock(lel); - const e = this.itemAt(B, items, i); + // Each index reads the element as join does (scr_dyn_arr_at, +1): + // from the current storage, undefined past the current length, + // and through the prototype for a hole. An element's toString + // can append to, shrink or reallocate the array; a getter that + // throws answers NULL with the exception pending. + host.declare(`declare ptr @scr_dyn_arr_at(ptr, double)`); + host.declare(`declare void @scr_dyn_release_v(ptr)`); + const index = B.tmp(); + B.line(`${index} = uitofp ${this.S} ${i} to double`); + const e = B.tmp(); + B.line(`${e} = call ptr @scr_dyn_arr_at(ptr %d, double ${index})`); + const missing = B.tmp(); + B.line(`${missing} = icmp eq ptr ${e}, null`); + const lGot = B.newLabel("ds.gt"); + B.condBr(missing, done, lGot); + B.startBlock(lGot); const ek = this.kindOf(B, e); const isU = B.tmp(); const isN = B.tmp(); @@ -3369,20 +3385,30 @@ export class LlDyn { B.line(`${isU} = icmp eq i32 ${ek}, ${DYN_KIND.UNDEF}`); B.line(`${isN} = icmp eq i32 ${ek}, ${DYN_KIND.NULL}`); B.line(`${unit} = or i1 ${isU}, ${isN}`); - const lSkip = B.newLabel("ds.sk"); const lRec = B.newLabel("ds.rc"); + const lSkip = B.newLabel("ds.sk"); B.condBr(unit, lSkip, lRec); B.startBlock(lSkip); + B.line(`call void @scr_dyn_release_v(ptr ${e})`); brNext(); B.startBlock(lRec); B.line(`call void @sc_ds_buf(ptr %b, ptr ${e})`); + B.line(`call void @scr_dyn_release_v(ptr ${e})`); + // The first exception an element's conversion throws ends the + // conversion; later elements are not read. + const thrown = B.tmp(); + for (const line of host.pendingTestLines(thrown)) B.line(line.trimStart()); + const lMore = B.newLabel("ds.mo"); + B.condBr(thrown, done, lMore); + B.startBlock(lMore); }); B.br(done); } B.startBlock(labels.get(DYN_KIND.OBJ)!); { // The checked-dynamic tree's error encoding renders Error.prototype.toString; - // plain objects are "[object Object]". + // other objects convert with ToPrimitive's string hint: a toString or + // valueOf of their own or of their prototypes, else "[object Object]". const marker = this.objGetLit(B, "%d", "%error"); const isErr = B.tmp(); B.line(`${isErr} = icmp ne ptr ${marker}, null`); @@ -3390,8 +3416,21 @@ export class LlDyn { const lPlain = B.newLabel("ds.pl"); B.condBr(isErr, lErr, lPlain); B.startBlock(lPlain); - this.puts(B, "%b", "[object Object]"); - B.br(done); + { + // Null leaves the exception the conversion threw pending. + host.declare(`declare ptr @scr_dyn_string_coerce_js(ptr)`); + host.declare(`declare void @scr_str_release(ptr)`); + const text = B.tmp(); + B.line(`${text} = call ptr @scr_dyn_string_coerce_js(ptr %d)`); + const converted = B.tmp(); + B.line(`${converted} = icmp ne ptr ${text}, null`); + const lPut = B.newLabel("ds.pp"); + B.condBr(converted, lPut, done); + B.startBlock(lPut); + this.putScrStr(B, "%b", text); + B.line(`call void @scr_str_release(ptr ${text})`); + B.br(done); + } B.startBlock(lErr); // Error.prototype.toString, reading name and message through the // prototype. Null leaves the exception its conversion threw pending. diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index e89d19f7f..4e818eb31 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -29,7 +29,6 @@ import { vAdapters, } from "./shapes.js"; import { emitLlvmLayouts } from "./layouts.js"; -import { moduleRuntimeFeatures } from "../../ir/runtime-features.js"; import { emitArraySearchHelpers } from "./array-search.js"; import { emitClassMembershipHelper } from "./classes.js"; import { type RegexMatcherCode, compileRegexMatcher } from "./regex-matchers.js"; @@ -138,7 +137,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // outlive the RC audit — release it with the globals (the runtime ABI’s // sc_release_globals tail). Only when the dispatch unit is even // linked (defineProps is the only writer). - const runtimeFeatures = moduleRuntimeFeatures(emitter.mod); + const runtimeFeatures = emitter.runtimeFeatures; const fnValueProps = [...emitter.fnValues]; if (fnValueProps.length > 0) emitter.declare(`declare void @scr_box_release(ptr)`); if (emitter.classObjs.size > 0) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 18cee0501..05e297304 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -54,11 +54,7 @@ import { VOID, classJsName, } from "../../ir/ir.js"; -import { - moduleRuntimeFeatures, - moduleUsesDynBuiltinStream, - moduleUsesMockTimers, -} from "../../ir/runtime-features.js"; +import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js"; import { captureFreeFunctions, observesStacks } from "../stack-observations.js"; import { type DenseArrayLoop } from "./dense-array-loops.js"; import { findInitializedByteLoopBindings } from "./byte-windows.js"; @@ -264,7 +260,9 @@ import { emitLibCall, } from "./lib-dispatch.js"; import { + assignCompactLayouts, buildClassGraph, + classFieldTypes, classMembershipIntervals, classFieldIndex, classPreLines, @@ -879,13 +877,16 @@ export class LlEmitter { /** The program calls mock.timers members (moduleUsesMockTimers): timer * calls emit scr_test.c's mock-aware entry points. */ readonly mockTimers: boolean; - private schedulesInContextValue: boolean | undefined = undefined; + private runtimeFeaturesValue: RuntimeFeatures | undefined; + /** The module's runtime features (moduleRuntimeFeatures), found with one + * walk of the module the first time any of them is read. */ + get runtimeFeatures(): RuntimeFeatures { + return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod)); + } /** Whether timer and tick scheduling goes through the *_als entry * points (RuntimeFeatures asyncContext). */ get schedulesInContext(): boolean { - if (this.schedulesInContextValue === undefined) - this.schedulesInContextValue = moduleRuntimeFeatures(this.mod).asyncContext; - return this.schedulesInContextValue; + return this.runtimeFeatures.asyncContext; } /** Emitted ref-kind resolve thunks for new Promise, interned per inner * typeKey → thunk symbol. */ @@ -901,10 +902,9 @@ export class LlEmitter { /** Identity-preserving static→dyn capsules for program classes and Web * APIs whose values remain directly observable. */ readonly liveDynRefAdapters = new Map(); - private dynBuiltinStreamUse: boolean | undefined; /** See moduleUsesDynBuiltinStream. */ get usesDynBuiltinStream(): boolean { - return (this.dynBuiltinStreamUse ??= moduleUsesDynBuiltinStream(this.mod)); + return this.runtimeFeatures.dynBuiltinStream; } /** Runtime-arm dispatchers for live union values. */ readonly liveDynUnionRefAdapters = new Map(); @@ -1141,7 +1141,7 @@ export class LlEmitter { ) { this.stackTraces = observesStacks(mod); this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined); - this.mockTimers = moduleUsesMockTimers(mod); + this.mockTimers = this.runtimeFeatures.mockTimers; this.debug = options.debugSources === undefined ? null @@ -1154,14 +1154,18 @@ export class LlEmitter { ); this.constantNumericTables = findConstantNumericTables(mod); this.initializerBindings = findInitializerBindings(mod); - this.int32Slots = analyzeInt32Slots(mod); + // One walk finds the dynamic field stores every analysis below consults. const dynamicStores = dynamicFieldStores(mod); + this.int32Slots = analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings: this.initializerBindings, + }); this.liveViewsReadOnly = mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0; this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map(); this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); this.programLifetime = new Set(mod.contracts?.programLifetime ?? []); - this.finalFields = analyzeFinalFields(mod); + this.finalFields = analyzeFinalFields(mod, false, dynamicStores); this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); @@ -1231,7 +1235,7 @@ export class LlEmitter { this.unframedFunctions = this.stackTraces ? captureFreeFunctions(mod, this.stackChecks, this.publishedTypes) : new Set(); - this.immutableFields = computeImmutableFields(mod); + this.immutableFields = computeImmutableFields(mod, dynamicStores); this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`)); this.indirectMayThrow = mt.indirect || mod.workers === true; for (const cls of mod.classes ?? []) { @@ -1356,7 +1360,7 @@ export class LlEmitter { this.receiverReaders = new AmbientReceiverReaders(this.fnByName); this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks); this.lazyCaptures = new LazyCaptures(this.fnByName); - const traced = computeTraced(mod); + const traced = computeTraced(mod, dynamicStores); this.tracedShapes = traced.shapes; this.tracedUnions = traced.unions; // A shape union's value is whichever record it holds, and the collector @@ -1492,6 +1496,15 @@ export class LlEmitter { classDefinitions: (cls, sizeOf) => regions.classDefs(cls, sizeOf), }; } + // ProgramLifetime hierarchies count nothing: their instances get the + // 32-bit header and packed fields (classes.ts LlClassMeta.compact), + // except where regions count them or a slot word follows the header. + if (this.sizeType === "i64" && mod.lifetimeRegions !== true) + assignCompactLayouts( + this.classMeta, + (root) => programLifetimeClass(this.shapeHost, root) && !root.threadSlot, + (cls) => classFieldTypes(this.shapeHost, cls, this.int32Slots), + ); for (const [name, rec] of RUNTIME_ERROR_CLASSES) { const meta = this.classMeta.get(name); if (!meta) break; // hand-written IR without the builtin defs: no stamps @@ -5114,6 +5127,7 @@ export class LlEmitter { post: meta.post, runtimeRooted: meta.root.def.runtime === true, idInRc: meta.idInRc, + compact: meta.compact, // Inline scalar-union fields are plain bits: nothing to push. fields: meta.def.fields.flatMap((f) => this.inlineUnions.classField(className, f.name) @@ -5154,15 +5168,21 @@ export class LlEmitter { /** Branches on "`obj` is published" (rc == SIZE_MAX, or a count of * UINT32_MAX in the low half for `count` i32: classes.ts - * LlClassMeta.idInRc); leaves the builder in the cold block and answers - * the continuation label. */ - private emitPublishedTest(obj: string, count: "i32" | "i64" = this.sizeType): string { + * LlClassMeta.idInRc, or the top bit of a compact header word for + * "compact": classes.ts COMPACT_PUBLISHED); leaves the builder in the cold + * block and answers the continuation label. */ + private emitPublishedTest(obj: string, count: "i32" | "i64" | "compact" = this.sizeType): string { const B = this.B; const rc = B.tmp(), frozen = B.tmp(), expected = B.tmp(); - B.line(`${rc} = load ${count}, ptr ${obj}`); - B.line(`${frozen} = icmp eq ${count} ${rc}, -1`); + if (count === "compact") { + B.line(`${rc} = load i32, ptr ${obj}`); + B.line(`${frozen} = icmp slt i32 ${rc}, 0`); + } else { + B.line(`${rc} = load ${count}, ptr ${obj}`); + B.line(`${frozen} = icmp eq ${count} ${rc}, -1`); + } this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`); const cold = B.newLabel("pub.frozen"), @@ -5203,7 +5223,10 @@ export class LlEmitter { if (className !== null && !published.classGuarded(className)) return; const B = this.B; const meta = className === null ? undefined : this.classMeta.get(className); - const ok = this.emitPublishedTest(obj, meta?.idInRc === true ? "i32" : this.sizeType); + const ok = this.emitPublishedTest( + obj, + meta?.compact === true ? "compact" : meta?.idInRc === true ? "i32" : this.sizeType, + ); let owner: string; if (meta === undefined) owner = this.cstr("Object"); else if (!meta.hierarchy) owner = this.cstr(meta.def.jsName ?? meta.def.name); diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 83cbe6d54..554306f6e 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -29,7 +29,7 @@ import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { emitUnionWiden } from "./expr-records.js"; import { emitJsIcMethod, jsIcMethods } from "./js-ic.js"; -import { DYN_BUFFER_NUM_METHODS } from "../../ir/runtime-features.js"; +import { DYN_BUFFER_METHODS } from "../../ir/runtime-features.js"; export function emitDynamicExpr( host: LlvmEmitterContext, @@ -234,11 +234,9 @@ export function emitDynamicExpr( }); argsPtr = arr; } - // Fixed-width Buffer method names take scr_bytes_dyn.c's entry, which + // Buffer.prototype method names take their scr_bytes_dyn.c entry, which // runs them on Buffer receivers and dispatches any other receiver. - const invoke = DYN_BUFFER_NUM_METHODS.has(e.method) - ? "scr_dyn_invoke_buffer_num" - : "scr_dyn_invoke_prepared"; + const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared"; host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`); let name = host.cstr(e.calleeName); if (e.calleeNameValue !== undefined) { @@ -1038,6 +1036,29 @@ export function emitDynamicExpr( B.line(`${n} = xor i1 ${t}, true`); return { name: n, type: e.type }; } + if (e.test === "dynamic") { + // A REF payload whose retain adapter is the dyn adapter (sc_cd's + // by-reference arm). + host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); + const kp = B.tmp(), + k = B.tmp(), + isRef = B.tmp(), + rp = B.tmp(), + r = B.tmp(), + isDyn = B.tmp(), + t = B.tmp(); + B.line(`${kp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 1`); + B.line(`${k} = load i32, ptr ${kp}`); + B.line(`${isRef} = icmp eq i32 ${k}, 4`); + B.line(`${rp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 5`); + B.line(`${r} = load ptr, ptr ${rp}`); + B.line(`${isDyn} = icmp eq ptr ${r}, @scr_dyn_retain_v`); + B.line(`${t} = and i1 ${isRef}, ${isDyn}`); + if (e.negated !== true) return { name: t, type: e.type }; + const n = B.tmp(); + B.line(`${n} = xor i1 ${t}, true`); + return { name: n, type: e.type }; + } if (e.test === "object") { host.declare(`declare zeroext i1 @scr_caught_is_object(ptr)`); const t = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index c95a1f14a..699e52575 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -853,7 +853,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr host.emitPendingCheck(); return { name: t, type: e.type }; } - if (e.fn === "emitter.emitFlex") { + if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") { const args = e.args.map((a) => host.emitExpr(a)); const n = args.length - 2; let payload = "null"; @@ -867,10 +867,57 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr } payload = arr; } - host.declare(`declare zeroext i1 @scr_emitter_emit_flex(ptr, ptr, ptr, ${host.sizeType})`); + const sym = e.fn === "emitter.emitAny" ? "scr_emitter_emit_any" : "scr_emitter_emit_flex"; + host.declare(`declare zeroext i1 @${sym}(ptr, ptr, ptr, ${host.sizeType})`); const t = B.tmp(); B.line( - `${t} = call i1 @scr_emitter_emit_flex(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`, + `${t} = call i1 @${sym}(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`, + ); + host.emitPendingCheck(); + return { name: t, type: e.type }; + } + if (e.fn === "emitter.emitMixed") { + // (recv, name, k, ...k dyn arguments, ...converted tuple): the dyn + // arguments ride an exact-length array like emitFlex, the tuple the + // pointer-classed variadic tail like emitter.emit. Borrowed; may throw. + const count = e.args[2]!; + const k = count.kind === "numLit" ? count.value : 0; + const args = [e.args[0]!, e.args[1]!, ...e.args.slice(3)].map((a) => host.emitExpr(a)); + let payload = "null"; + if (k > 0) { + const arr = B.slot(); + B.entryAllocas.push(`${arr} = alloca [${k} x ptr]`); + for (let i = 0; i < k; i++) { + const slot = B.tmp(); + B.line(`${slot} = getelementptr inbounds [${k} x ptr], ptr ${arr}, i64 0, i64 ${i}`); + B.line(`store ptr ${args[i + 2]!.name}, ptr ${slot}`); + } + payload = arr; + } + const tuple = args.slice(2 + k).map((a) => { + const ty = host.llType(a.type); + if (ty === "double" || ty === "i1") { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty} ; EventEmitter scalar argument`); + B.line(`store ${ty} ${a.name}, ptr ${slot}`); + return `ptr ${slot}`; + } + return `ptr ${a.name}`; + }); + host.declare( + `declare zeroext i1 @scr_emitter_emit_mixed(ptr, ptr, ptr, ${host.sizeType}, ...)`, + ); + const t = B.tmp(); + B.line( + `${t} = call zeroext i1 (ptr, ptr, ptr, ${host.sizeType}, ...) @scr_emitter_emit_mixed(` + + [ + `ptr ${args[0]!.name}`, + `ptr ${args[1]!.name}`, + `ptr ${payload}`, + `${host.sizeType} ${k}`, + ...tuple, + ].join(", ") + + `)`, ); host.emitPendingCheck(); return { name: t, type: e.type }; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 299101912..1c7604c34 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -793,6 +793,8 @@ export const LIB_FN_SYMS: Record = { "fsp.rename": "scr_fsp_rename", "fsp.readdir": "scr_fsp_readdir", "fsp.rm": "scr_fsp_rm", + "fsp.rmOpts": "scr_fsp_rm_opts", + "fsp.rmRetry": "scr_fsp_rm_opts_retry", "fsp.stat": "scr_fsp_stat", "fsp.realpath": "scr_fsp_realpath", "fsp.lstat": "scr_fsp_lstat", @@ -1070,6 +1072,7 @@ export const LIB_FN_SYMS: Record = { "emitter.off": "scr_emitter_off", "emitter.offDyn": "scr_emitter_off_dyn", "emitter.onFlex": "scr_emitter_on_flex", + "emitter.onAny": "scr_emitter_on_any", "emitter.checkListener": "scr_emitter_check_listener", "emitter.removeAll": "scr_emitter_remove_all", "emitter.emitError": "scr_emitter_emit_error", @@ -1078,6 +1081,8 @@ export const LIB_FN_SYMS: Record = { "emitter.countDyn": "scr_emitter_listener_count_dyn", "emitter.names": "scr_emitter_event_names", "emitter.listeners": "scr_emitter_listeners", + "emitter.listenersDyn": "scr_emitter_listeners_dyn", + "emitter.hasTyped": "scr_emitter_has_typed", "emitter.setMax": "scr_emitter_set_max", "emitter.setMaxChk": "scr_emitter_set_max_chk", "emitter.setDefaultMaxChk": "scr_emitter_set_default_max_chk", diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index 15221bbc3..ebe9b1a64 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -25,6 +25,7 @@ import type { IrClassDef, IrRecordShape, IrType, IrUnionDef, SrcLoc } from "../. import { mapOf, STRING, typeKey } from "../../ir/ir.js"; import { mangleClassStruct, mangleRecordStruct } from "../mangle.js"; import { FN_ATTRS } from "./shapes.js"; +import { COMPACT_ID_MASK } from "./classes.js"; import { shapeTagFn } from "./nullable-unions.js"; import type { InlineUnionFields } from "./inline-unions.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -66,6 +67,8 @@ export interface PublishClassInfo { /** Instances keep their class id in the rc word (classes.ts * LlClassMeta.idInRc). */ idInRc: boolean; + /** Instances have the 32-bit header (classes.ts LlClassMeta.compact). */ + compact: boolean; /** The class's own and inherited fields: GEP index and storage type. */ fields: { name: string; index: number; type: IrType }[]; } @@ -76,6 +79,11 @@ const PUB_KIND_UNION = 5; /** SCR_PUB_CLASS_ID, or'ed into a kind: the object keeps its class id * in the high half of its rc word (classes.ts LlClassMeta.idInRc). */ const PUB_CLASS_ID = 0x100; +/** SCR_PUB_COMPACT, or'ed into a kind: the object has the 32-bit header + * (classes.ts compactHeaderWord), whose top bit is the mark. */ +const PUB_COMPACT = 0x200; +/** COMPACT_PUBLISHED as an i32 constant. */ +const COMPACT_MARK = "-2147483648"; /** SCR_PUB_LOG_COUNT (scr_publish.c), in a log entry's `buf`: `rc` is the * count of the low half. */ const PUB_LOG_COUNT = "-2147483648"; @@ -223,7 +231,11 @@ export class PublishWalkers { // hold time-value bits that no walker may push. if (def.arms.some((arm) => scalarRefusal(arm))) throw new LlvmUnsupportedError(`publish of ${typeKey(t)}`, loc); - const live = arms.flatMap((a) => (a.fn === null ? [] : [{ tag: a.tag, fn: a.fn }])); + const live = arms.flatMap((a) => + a.fn === null + ? [] + : [{ tag: a.tag, fn: a.fn, compact: this.compactRef(def.arms[a.tag]!) }], + ); if (this.host.shapeUnion(t)) { // The value is the record itself (or a unit encoding): no box to // mark; the arm's walker marks and walks the record. @@ -256,7 +268,7 @@ export class PublishWalkers { for (const a of live) lines.push( `arm${a.tag}:`, - ...this.push("%slot", a.fn, `u${a.tag}`), + ...this.push("%slot", a.fn, `u${a.tag}`, a.compact), ` br label %done`, ); } @@ -316,18 +328,25 @@ export class PublishWalkers { const lines = [ head, `entry:`, - ...(info.idInRc + ...(info.compact ? [ - ` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`, - ` %id = load i32, ptr %idp`, + ` %idw = load i32, ptr %o ; the class id (classes.ts compactHeaderWord)`, + ` %id = and i32 %idw, ${COMPACT_ID_MASK}`, ` %idpre = add i32 %id, -1`, ` %pre = zext i32 %idpre to ${this.host.sizeType}`, ] - : [ - ` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`, - ` %vt = load ptr, ptr %vtp`, - ` %pre = load ${this.host.sizeType}, ptr %vt`, - ]), + : info.idInRc + ? [ + ` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`, + ` %id = load i32, ptr %idp`, + ` %idpre = add i32 %id, -1`, + ` %pre = zext i32 %idpre to ${this.host.sizeType}`, + ] + : [ + ` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`, + ` %vt = load ptr, ptr %vtp`, + ` %pre = load ${this.host.sizeType}, ptr %vt`, + ]), ` switch ${this.host.sizeType} %pre, label %other [ ${cases.map((c) => `${this.host.sizeType} ${c.pre}, label %c${c.pre}`).join(" ")} ]`, ]; for (const c of cases) @@ -363,6 +382,7 @@ export class PublishWalkers { path, loc, info.idInRc, + info.compact, ); } @@ -374,23 +394,26 @@ export class PublishWalkers { * targets), and an object with a collector header leaves its candidate * buffer through scr_cyc_pub_take. Anything else calls scr_pub_mark, as * does every mark of an RC-audit build. */ - private markLines(cyc: boolean, kind: number, classId = false): string[] { + private markLines(cyc: boolean, kind: number, classId = false, compact = false): string[] { + const flags = compact ? PUB_COMPACT : classId ? PUB_CLASS_ID : 0; const call = (name: string): string[] => [ - ` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${classId ? kind | PUB_CLASS_ID : kind})`, + ` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${kind | flags})`, ` br i1 %${name}, label %walk, label %done`, ]; if (this.host.rcAudit === true) return call("m"); const S = this.host.sizeType; // An object that keeps its class id in the rc word is marked and // logged through its count, the low half. - const C = classId ? "i32" : S; + // A compact object's header word is logged whole (the restore writes + // the low 32 bits back) and marked by its top bit. + const C = classId || compact ? "i32" : S; const field = (name: string, slot: number): string[] => [ ` %${name}p = getelementptr inbounds ptr, ptr %ctx, i32 ${slot}`, ` %${name} = load ${S === "i64" ? "i64" : "i32"}, ptr %${name}p`, ]; const lines = [ ` %mrc = load ${C}, ptr %o`, - ` %mimm = icmp eq ${C} %mrc, -1`, + compact ? ` %mimm = icmp slt i32 %mrc, 0` : ` %mimm = icmp eq ${C} %mrc, -1`, ` br i1 %mimm, label %done, label %mtest`, `mtest:`, ` %mrefp = getelementptr inbounds ptr, ptr %ctx, i32 6`, @@ -408,7 +431,7 @@ export class PublishWalkers { ok = "%mok2"; } lines.push(` br i1 ${ok}, label %mfast, label %mslow`, `mslow:`, ...call("ms"), `mfast:`); - let buf = classId ? String(PUB_LOG_COUNT) : "0"; + let buf = classId || compact ? String(PUB_LOG_COUNT) : "0"; if (cyc) { // ScrCycHdr.buf, the word before the object: its top bit while the // object is a buffered candidate. @@ -443,7 +466,9 @@ export class PublishWalkers { ` store i32 ${buf}, ptr %mebuf`, ` %mlogged1 = add ${S} %mlogged, 1`, ` store ${S} %mlogged1, ptr %mloggedp`, - ` store ${C} -1, ptr %o`, + ...(compact + ? [` %mpub = or i32 %mrc, ${COMPACT_MARK}`, ` store i32 %mpub, ptr %o`] + : [` store ${C} -1, ptr %o`]), ` br label %walk`, ); return lines; @@ -453,15 +478,18 @@ export class PublishWalkers { * immortal references (the parent links of a tree, values published * before) are the ones scr_pub_push skips: they are tested here, without * the call. The lines end in a fresh block labelled `pushed${id}`. */ - private push(v: string, fn: string, id: string): string[] { + private push(v: string, fn: string, id: string, compact = false): string[] { return [ ` %null${id} = icmp eq ptr ${v}, null`, ` br i1 %null${id}, label %pushed${id}, label %live${id}`, `live${id}:`, // The count (the low half of a 64-bit rc word) is UINT32_MAX for an - // immortal object of every kind (scr_rc.h SCR_RC_COUNT). + // immortal object of every kind (scr_rc.h SCR_RC_COUNT), and a + // compact object's header word has its top bit set once published. ` %rc${id} = load i32, ptr ${v}`, - ` %immortal${id} = icmp eq i32 %rc${id}, -1`, + compact + ? ` %immortal${id} = icmp slt i32 %rc${id}, 0` + : ` %immortal${id} = icmp eq i32 %rc${id}, -1`, ` br i1 %immortal${id}, label %pushed${id}, label %push${id}`, `push${id}:`, ...this.pushLines(v, fn, id), @@ -497,6 +525,12 @@ export class PublishWalkers { ]; } + /** Whether references of type `t` are compact class instances. */ + private compactRef(t: IrType): boolean { + const arm = this.host.nullableArm(t) ?? t; + return arm.kind === "object" && this.host.classInfo(arm.className).compact; + } + private objectBody( head: string, struct: string, @@ -505,8 +539,14 @@ export class PublishWalkers { path: string, loc: SrcLoc, classId = false, + compact = false, ): string[] { - const lines = [head, `entry:`, ...this.markLines(traced, PUB_KIND_OBJECT, classId), `walk:`]; + const lines = [ + head, + `entry:`, + ...this.markLines(traced, PUB_KIND_OBJECT, classId, compact), + `walk:`, + ]; let n = 0; for (const member of members) { const fn = this.walker(member.type, `${path}.${member.name}`, loc); @@ -519,7 +559,7 @@ export class PublishWalkers { lines.push( ` %f${n} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${member.index} ; ${llvmComment(member.name)}`, ` %v${n} = load ptr, ptr %f${n}`, - ...this.push(`%v${n}`, fn, `${n}`), + ...this.push(`%v${n}`, fn, `${n}`, this.compactRef(member.type)), ); n++; } diff --git a/packages/compiler/src/backend/llvm/wasm-modules.ts b/packages/compiler/src/backend/llvm/wasm-modules.ts index 8a7045687..d08497214 100644 --- a/packages/compiler/src/backend/llvm/wasm-modules.ts +++ b/packages/compiler/src/backend/llvm/wasm-modules.ts @@ -1,12 +1,18 @@ /* The WebAssembly modules translated into a program (IrModule.wasmModules): - * each module's translation (wasm/translate.ts) and the registry the - * runtime looks modules up in (scr_wasm_registry, scr_wasm_abi.h). The + * each module's translation (wasm/translate.ts), the registry the runtime + * looks modules up in (scr_wasm_registry, scr_wasm_abi.h), and the table of + * modules the build saw but does not translate (scr_wasm_untranslated). The * translations join the program module, so the native helper optimizes, * partitions and caches them with it. Programs that do not use the * WebAssembly API carry no wasmModules and emit nothing here. */ -import type { IrModule } from "../../ir/ir.js"; +import type { IrModule, IrWasmUntranslated } from "../../ir/ir.js"; import { decodeBase64 } from "../../wasm/base64.js"; -import { translateWasmModule, WasmTranslateError } from "../../wasm/translate.js"; +import { + cBytes, + translateWasmModule, + utf8Bytes, + WasmTranslateError, +} from "../../wasm/translate.js"; function hexBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); @@ -28,11 +34,38 @@ export function wasmBoundsChecks(options: { return /windows|mingw|cygwin|win32|msvc/.test(target); } -/** The top-level lines of every translated module plus the registry. - * `declared` holds the declarations the module already carries; runtime - * entry points it lacks are declared here. A module the translator - * cannot translate is left out of the registry, so loading it at run - * time rejects with the CompileError for untranslated bytes. */ +/** scr_wasm_untranslated: hash, byte length and reason per module. */ +function emitUntranslated(untranslated: readonly IrWasmUntranslated[]): string[] { + if (untranslated.length === 0) + return [`@scr_wasm_untranslated = constant { i64, ptr } { i64 0, ptr null }`]; + const out: string[] = []; + const entries: string[] = []; + for (let index = 0; index < untranslated.length; index++) { + const module = untranslated[index]!; + const reason = new Uint8Array(utf8Bytes(module.reason)); + out.push( + `@sc_wasm_untranslated_reason${index} = private unnamed_addr constant [${reason.length} x i8] c"${cBytes(reason, 0, reason.length)}"`, + ); + const digest = hexBytes(module.sha256); + const sha: string[] = []; + for (let i = 0; i < 32; i++) sha.push(`i8 ${digest[i]!}`); + entries.push( + `{ [32 x i8], i64, ptr, i64 } { [32 x i8] [${sha.join(", ")}], i64 ${module.size}, ptr @sc_wasm_untranslated_reason${index}, i64 ${reason.length} }`, + ); + } + out.push( + `@sc_wasm_untranslated_modules = private constant [${entries.length} x { [32 x i8], i64, ptr, i64 }] [${entries.join(", ")}]`, + `@scr_wasm_untranslated = constant { i64, ptr } { i64 ${entries.length}, ptr @sc_wasm_untranslated_modules }`, + ); + return out; +} + +/** The top-level lines of every translated module plus the registry and + * the untranslated table. `declared` holds the declarations the module + * already carries; runtime entry points it lacks are declared here. A + * module the translator cannot translate joins the untranslated table, so + * loading it at run time rejects with a CompileError that names the + * translator's reason. */ export function emitWasmModules( mod: IrModule, declared: ReadonlySet, @@ -43,6 +76,7 @@ export function emitWasmModules( const out: string[] = []; const decls = new Set(declared); const descriptors: string[] = []; + const untranslated = [...(mod.wasmUntranslated ?? [])]; for (const module of modules) { const bytes = decodeBase64(module.bytes); if (bytes === null) continue; @@ -53,8 +87,9 @@ export function emitWasmModules( customSections: mod.wasmCustomSections === true, }); } catch (error) { - if (error instanceof WasmTranslateError) continue; - throw error; + if (!(error instanceof WasmTranslateError)) throw error; + untranslated.push({ sha256: module.sha256, size: bytes.length, reason: error.message }); + continue; } for (const decl of translation.decls) { if (decls.has(decl)) continue; @@ -72,6 +107,7 @@ export function emitWasmModules( `@scr_wasm_registry = constant { i64, ptr } { i64 ${descriptors.length}, ptr @sc_wasm_registry_modules }`, ); } + out.push(...emitUntranslated(untranslated)); out.push(""); return out; } diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index ecfe360f9..39e570cd5 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -1,8 +1,8 @@ import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { constants } from "node:fs"; +import { createHash, type Hash } from "node:crypto"; +import { closeSync, constants, openSync, readSync } from "node:fs"; import { createRequire } from "node:module"; -import { access, chmod, mkdir, readFile, rename, rm, stat } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { @@ -73,9 +73,31 @@ export interface NativeCodegenArtifact { const resolvedHelperCache = new Map>(); +/** LLVM text already written to `file`, of `bytes` bytes, which a build passes on without reading it. */ +export interface LlvmFile { + readonly file: string; + readonly bytes: number; +} + +/** LLVM text as a string, as parts to concatenate, or as a file. */ +export type LlvmInput = string | readonly string[] | LlvmFile; + +export function isLlvmFile(llvm: LlvmInput): llvm is LlvmFile { + return typeof llvm === "object" && !Array.isArray(llvm); +} + +/** The size of `llvm` in bytes. */ +export function llvmInputBytes(llvm: LlvmInput): number { + if (isLlvmFile(llvm)) return llvm.bytes; + return (typeof llvm === "string" ? [llvm] : llvm).reduce( + (bytes, part) => bytes + Buffer.byteLength(part), + 0, + ); +} + export interface NativeCodegenOptions { outputPath: string; - llvm: string | readonly string[]; + llvm: LlvmInput; outputKind: NativeCodegenOutputKind; sourcePath: string; optimization?: "0" | "1" | "2" | "3" | "s" | "z"; @@ -278,14 +300,31 @@ async function resolveHelper( return pending; } +/** Adds the bytes of `file` to `hash`, as `hash.update` of the text written there would. */ +function updateHashFromFile(hash: Hash, file: string): void { + const fd = openSync(file, "r"); + try { + const buffer = Buffer.allocUnsafe(4 * 1024 * 1024); + for (;;) { + const read = readSync(fd, buffer, 0, buffer.length, null); + if (read === 0) break; + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(fd); + } +} + function cacheKey( options: NativeCodegenOptions, target: NativeTargetSpec, helper: HelperIdentity, ): string { const hash = createHash("sha256").update("scriptc-native-codegen-v1\0"); - for (const part of typeof options.llvm === "string" ? [options.llvm] : options.llvm) - hash.update(part); + if (isLlvmFile(options.llvm)) updateHashFromFile(hash, options.llvm.file); + else + for (const part of typeof options.llvm === "string" ? [options.llvm] : options.llvm) + hash.update(part); return hash .update("\0") .update(JSON.stringify(target)) @@ -424,7 +463,8 @@ export async function emitNativeArtifact( // concurrent build of another entry that has the same output stem. const input = privateSiblingPath(options.outputPath, "native-input"); try { - await writeLlvmSource(input, options.llvm, 0o600); + if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input); + else await writeLlvmSource(input, options.llvm, 0o600); await invoke(helper.binaryPath, [ "emit", "--input", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 1b5da3b78..b6ef6501b 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -58,9 +58,9 @@ export interface NativeLinkFeatures { /** events.once with options: scr_events_once_signal.c. Absent (not * false) for programs without it. */ eventsOnceSignal?: boolean; - /** Fixed-width Buffer methods called on checked-dynamic values link + /** Buffer.prototype methods called on checked-dynamic values link * scr_bytes_dyn.c; absent (false) for programs without them. */ - dynBufferNum?: boolean; + dynBuffer?: boolean; /** Builtin functions read as values (Buffer.byteLength, Buffer.alloc, * fs.realpathSync) link scr_builtin_values.c; absent (false) otherwise. */ builtinValues?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 88d6d8619..6fd670cab 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -123,9 +123,9 @@ export interface CcOptions { /** events.once with options (events.onceSignal): compiles * scr_events_once_signal.c. */ eventsOnceSignal?: boolean; - /** Fixed-width Buffer methods called on checked-dynamic values: + /** Buffer.prototype methods called on checked-dynamic values: * compiles scr_bytes_dyn.c. */ - dynBufferNum?: boolean; + dynBuffer?: boolean; /** Builtin functions read as values: compiles scr_builtin_values.c. */ builtinValues?: boolean; /** The program constructs EventTarget/Event/CustomEvent values or @@ -324,6 +324,9 @@ export interface LibArchiveOptions { threadInstances?: boolean; /** IR-detected link gates (the compileC precedent, refusal-narrowed). */ dynInvoke?: boolean; + /** Buffer.prototype methods on checked-dynamic values: scr_bytes_dyn.c, + * with dynInvoke. */ + dynBuffer?: boolean; /** See CompileOptions.dynTier: the scr_js_* units. */ dynTier?: boolean; regex?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 95b449cbd..e0bc19aec 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -180,8 +180,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), - // Fixed-width Buffer methods called on checked-dynamic values. - unit((u) => u.opts.dynBufferNum === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), + // Buffer.prototype methods called on checked-dynamic values. + unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), // Builtin functions read as values in JavaScript dependencies. unit((u) => u.opts.builtinValues === true, "scr_builtin_values.c"), unit((u) => u.opts.dc, "scr_dc.c"), diff --git a/packages/compiler/src/backend/native/library.ts b/packages/compiler/src/backend/native/library.ts index 3907fd5d8..f04e9926c 100644 --- a/packages/compiler/src/backend/native/library.ts +++ b/packages/compiler/src/backend/native/library.ts @@ -112,6 +112,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise ...(opts.assert && opts.bigint ? ["scr_bigint_assert.c"] : []), ...(opts.emitter ? ["scr_events_emitter.c", "scr_dyn_handle.c"] : []), ...(opts.dynInvoke ? ["scr_dyn_invoke.c"] : []), + ...(opts.dynInvoke && opts.dynBuffer ? ["scr_bytes_dyn.c"] : []), ...(opts.dynTier === true ? ["scr_js_value.c", "scr_js_object.c"] : []), ...(opts.zlib ? ["scr_zlib.c"] : []), ...(opts.copying ? ["scr_copying.c"] : []), diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index d7d4dc929..fd5aef1c4 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -88,7 +88,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { handleProps: boolean; dynForAwait: boolean; eventsOnceSignal: boolean; - dynBufferNum: boolean; + dynBuffer: boolean; builtinValues: boolean; warningReplace: boolean; stdioExtended: boolean; @@ -286,7 +286,7 @@ export function effectiveRuntimeFeatures( handleProps: features.handleProps === true, dynForAwait: features.dynForAwait === true, eventsOnceSignal: features.eventsOnceSignal === true, - dynBufferNum: features.dynBufferNum === true, + dynBuffer: features.dynBuffer === true, builtinValues: features.builtinValues === true, warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 1271eadd2..94b136c90 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -13,9 +13,11 @@ import { } from "../backend/native-toolchain.js"; import { installDarwinDebugSymbols, readDarwinDebugSymbols } from "../backend/debug-symbols.js"; import { + hasEarlyExecutableNativeBuild, publishEarlyExecutableCache, readEarlyExecutableCache, readRoutedExecutableCache, + recordEarlyExecutableNativeBuild, type EarlyExecutableCacheOptions, type EarlyExecutableNativeFeatures, } from "./executable-cache.js"; @@ -596,3 +598,38 @@ test("the route index still isolates eager and deferred native environments", as )?.executableRestored, ).toBe(true); }); + +test("a recorded native build is found by its invocation", async () => { + const f = await fixture(); + expect(await hasEarlyExecutableNativeBuild(null, f.options)).toBe(false); + expect(await hasEarlyExecutableNativeBuild(f.root, f.options)).toBe(false); + await recordEarlyExecutableNativeBuild(null, f.options); + await recordEarlyExecutableNativeBuild(f.root, f.options); + expect(await hasEarlyExecutableNativeBuild(f.root, f.options)).toBe(true); + // The record does not depend on the compiler implementation or the native environment. + const rebuilt: EarlyExecutableCacheOptions = { + ...f.options, + implementation: "b".repeat(64), + nativeEnvironment: "another driver", + }; + expect(await hasEarlyExecutableNativeBuild(f.root, rebuilt)).toBe(true); + expect( + await hasEarlyExecutableNativeBuild(f.root, { + ...f.options, + outPath: join(f.options.outDir, "other"), + }), + ).toBe(false); + expect(await hasEarlyExecutableNativeBuild(f.root, { ...f.options, sanitize: true })).toBe(false); + // Publishing an executable does not record a native build. + const other = { ...f.options, outPath: join(f.options.outDir, "published") }; + const tracker = new FrontendInputTracker(); + tracker.run(() => trackedReadFile(f.source)); + await publishEarlyExecutableCache(f.root, other, { + llvmPath: f.llvmPath, + irPath: f.irPath, + native, + executableRestored: false, + frontend: tracker.snapshot(), + }); + expect(await hasEarlyExecutableNativeBuild(f.root, other)).toBe(false); +}); diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index a56afcf2a..656c62963 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -71,7 +71,7 @@ export interface EarlyExecutableNativeFeatures { handleProps?: boolean; dynForAwait?: boolean; eventsOnceSignal?: boolean; - dynBufferNum?: boolean; + dynBuffer?: boolean; builtinValues?: boolean; events: boolean; emitter: boolean; @@ -338,6 +338,39 @@ function routePath( return join(root, "early-exe-route", routeKey(options)); } +function nativeBuildMarkerPath( + root: string, + options: Omit, +): string { + return join(root, "early-exe-native-build", routeKey(options)); +} + +/** Records that a build of the invocation `options` emitted its native program object, so the + * native helper's partition cache holds its partitions. Unlike the route, the record does not + * depend on the build publishing its executable. */ +export async function recordEarlyExecutableNativeBuild( + root: string | null, + options: Omit, +): Promise { + if (root === null) return; + const marker = nativeBuildMarkerPath(root, options); + await mkdir(dirname(marker), { recursive: true, mode: 0o700 }); + await writeFile(marker, "", { mode: 0o600 }); +} + +/** Whether an earlier build of the invocation `options` recorded its native program object + * (`recordEarlyExecutableNativeBuild`). False when there is no cache. */ +export async function hasEarlyExecutableNativeBuild( + root: string | null, + options: Omit, +): Promise { + if (root === null) return false; + return stat(nativeBuildMarkerPath(root, options)).then( + (info) => info.isFile(), + () => false, + ); +} + function routeIntegrity(stamp: Omit): string { return createHash("sha256") .update("early-executable-route-stamp-v2\0") diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index d97497c70..4afae7c7d 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -1,5 +1,6 @@ import type { CompileFailure, CompileRequestOptions } from "../compile-types.js"; import type { FfiProfile } from "../ffi/ffi-manifest.js"; +import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, npmStaticRefusalDiag, @@ -112,6 +113,7 @@ export function prepareExecutableModule( } } finally { fe.dispose(); + frontendDisposed(); timing("frontend-dispose"); } diff --git a/packages/compiler/src/frontend-process-args.test.ts b/packages/compiler/src/frontend-process-args.test.ts new file mode 100644 index 000000000..a6e9b52e5 --- /dev/null +++ b/packages/compiler/src/frontend-process-args.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "vitest"; +import { frontendProcessExecArgv } from "./frontend-process-args.js"; + +test("the frontend process keeps heap limits and loaders", () => { + expect( + frontendProcessExecArgv([ + "--max-old-space-size=8192", + "--stack-size=4000", + "--import", + "tsx", + "-r", + "./setup.cjs", + "--conditions=development", + "--enable-source-maps", + ]), + ).toEqual([ + "--max-old-space-size=8192", + "--stack-size=4000", + "--import", + "tsx", + "-r", + "./setup.cjs", + "--conditions=development", + "--enable-source-maps", + ]); +}); + +test("the frontend process drops evaluated code, inspectors, watch and test runner options", () => { + expect( + frontendProcessExecArgv([ + "--max-old-space-size=8192", + "--input-type=module", + "--eval", + "await compile(process.argv[1], {})", + ]), + ).toEqual(["--max-old-space-size=8192"]); + expect(frontendProcessExecArgv(["-e", "code", "-p", "code", "-pe", "code"])).toEqual([]); + expect( + frontendProcessExecArgv(["--eval=code", "--print=code", "--input-type", "module"]), + ).toEqual([]); + expect( + frontendProcessExecArgv([ + "--inspect", + "--inspect-brk=9229", + "--inspect-port", + "9230", + "--watch", + "--watch-path=src", + "--test", + "--test-name-pattern", + "build", + "--test-reporter=spec", + "--expose-gc", + ]), + ).toEqual(["--expose-gc"]); +}); diff --git a/packages/compiler/src/frontend-process-args.ts b/packages/compiler/src/frontend-process-args.ts new file mode 100644 index 000000000..dde9d002f --- /dev/null +++ b/packages/compiler/src/frontend-process-args.ts @@ -0,0 +1,54 @@ +/** Options of the compiler's own process that the frontend process (frontend-process.ts) must not + * receive, with the options among them that take their value as the next argument: the code of + * --eval and --print would run in place of the frontend process's entry, and the inspector, watch + * and test runner options belong to the process that was started with them. */ +const OPTIONS_WITH_VALUES = new Set([ + "-e", + "--eval", + "-p", + "--print", + "--input-type", + "--inspect-port", + "--debug-port", + "--watch-path", + "--test-concurrency", + "--test-coverage-branches", + "--test-coverage-exclude", + "--test-coverage-functions", + "--test-coverage-include", + "--test-coverage-lines", + "--test-global-setup", + "--test-isolation", + "--test-name-pattern", + "--test-reporter", + "--test-reporter-destination", + "--test-shard", + "--test-skip-pattern", + "--test-timeout", +]); +const OPTIONS = new Set(["-i", "--interactive", "-c", "--check", "--test"]); +const OPTION_PREFIXES = ["--inspect", "--debug-port", "--watch", "--test-", "--input-type="]; + +/** The Node.js options of the frontend process: `execArgv` without the options that run code + * (--eval, --print), open an inspector, or watch or test, so that its own entry runs with the + * compiler process's remaining options, such as heap limits and loaders. */ +export function frontendProcessExecArgv(execArgv: readonly string[]): string[] { + const kept: string[] = []; + for (let index = 0; index < execArgv.length; index++) { + const option = execArgv[index]!; + // A short option group such as -pe takes the code that follows it. + if (OPTIONS_WITH_VALUES.has(option) || /^-[a-z]*[ep][a-z]*$/i.test(option)) { + index++; + continue; + } + if ( + OPTIONS.has(option) || + option.startsWith("--eval=") || + option.startsWith("--print=") || + OPTION_PREFIXES.some((prefix) => option.startsWith(prefix)) + ) + continue; + kept.push(option); + } + return kept; +} diff --git a/packages/compiler/src/frontend-process.ts b/packages/compiler/src/frontend-process.ts new file mode 100644 index 000000000..a3d59516f --- /dev/null +++ b/packages/compiler/src/frontend-process.ts @@ -0,0 +1,18 @@ +// The frontend process of an executable build (FrontendProcess and runFrontendProcess in index.ts): +// loads the compiler, then reads the paths of its request and result from stdin. Without them it +// exits: the build found its executable without it. +import { enableCompileCache } from "node:module"; + +try { + enableCompileCache(); +} catch { + // Bytecode caching only speeds up loading. +} +const { runFrontendProcess } = await import("./index.js"); +let input = ""; +for await (const chunk of process.stdin) input += String(chunk); +const [requestPath, resultPath] = input.split("\n"); +if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined) + await runFrontendProcess(requestPath, resultPath); +// The frontend's own helpers (the checker process, timers) must not keep this process alive. +process.exit(0); diff --git a/packages/compiler/src/frontend/dispose-hook.ts b/packages/compiler/src/frontend/dispose-hook.ts new file mode 100644 index 000000000..ccc9b19aa --- /dev/null +++ b/packages/compiler/src/frontend/dispose-hook.ts @@ -0,0 +1,14 @@ +/* What runs right after a build disposes its frontend. The native compiler + * sets it to a full cycle collection (native/cli.ts): the frontend's syntax + * trees and program state refer to each other, so disposing them leaves + * cyclic garbage that reference counting cannot free. Under Node it is + * unset. */ +let afterDispose: (() => void) | null = null; + +export function setFrontendDisposedHook(hook: () => void): void { + afterDispose = hook; +} + +export function frontendDisposed(): void { + if (afterDispose !== null) afterDispose(); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 05028e1df..4de69a3f6 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -863,15 +863,22 @@ export function lowerFsPromisesMkdirCall( : { kind: "libCall", fn: "fsp.mkdirRecursive", args: [path], type, loc }; } -// rmSync(p, options): literal { recursive?, force? } booleans — the -// cleanup shape rmSync(dir, { recursive: true, force: true }) — -// plus the maxRetries/retryDelay numbers (the tmpdir-harness shape +// rmSync(p, options) and fs.promises.rm(p, options): literal +// { recursive?, force? } booleans — the cleanup shape +// rmSync(dir, { recursive: true, force: true }) — plus the +// maxRetries/retryDelay numbers (the tmpdir-harness shape // rmSync(p, { maxRetries: 3, recursive: true, force: true })): those // lower as ordinary number expressions into the retry libCall, whose // runtime implements Node's linear-backoff retry on // EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM. The retry-free shape keeps the -// historical rmOptsSync lowering unchanged. -export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { +// historical rmOptsSync lowering unchanged. The promise form runs the same +// removal and rejects with its error. +export function lowerFsRemoveCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + promise: boolean, +): IrExpr { const opts = literalBoolOptions( expr.arguments.nodes[1]!, ["recursive", "force"], @@ -879,7 +886,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc ); if (opts === null) { lowerer.noLowering( - "rmSync with an options argument beyond { recursive, force, maxRetries, retryDelay }", + `${promise ? "fs.promises.rm" : "rmSync"} with an options argument beyond { recursive, force, maxRetries, retryDelay }`, expr.arguments.nodes[1]!, "the recursive/force flags must be boolean literals; maxRetries/retryDelay are numbers; other options have no lowering", ); @@ -888,12 +895,13 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc const recursive = boolLit(opts.bools["recursive"] === true, loc); const force = boolLit(opts.bools["force"] === true, loc); + const type: IrType = promise ? { kind: "promise", inner: VOID } : VOID; if (opts.exprs["maxRetries"] === undefined && opts.exprs["retryDelay"] === undefined) { return { kind: "libCall", - fn: "fs.rmOptsSync", + fn: promise ? "fsp.rmOpts" : "fs.rmOptsSync", args: [path, recursive, force], - type: VOID, + type, loc, }; } @@ -904,7 +912,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc node ? lowerer.lowerExprExpecting(node, F64) : { kind: "numLit", value: dflt, type: F64, loc }; return { kind: "libCall", - fn: "fs.rmRetrySync", + fn: promise ? "fsp.rmRetry" : "fs.rmRetrySync", args: [ path, recursive, @@ -912,7 +920,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc num(opts.exprs["maxRetries"], 0), num(opts.exprs["retryDelay"], 100), ], - type: VOID, + type, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index c85f2c777..970a474c7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -292,7 +292,10 @@ export function lowerBuiltinModuleCall( return lowerFsPromisesMkdirCall(lowerer, expr, loc); } if (bi.module === "fs" && bi.member === "rmSync" && expr.arguments.nodes.length === 2) { - return lowerFsRemoveCall(lowerer, expr, loc); + return lowerFsRemoveCall(lowerer, expr, loc, false); + } + if (bi.module === "fs/promises" && bi.member === "rm" && expr.arguments.nodes.length === 2) { + return lowerFsRemoveCall(lowerer, expr, loc, true); } // accessSync(p, mode?) and truncateSync(p, len?), with their promise // spellings: an omitted mode is Node's F_OK (0) and an omitted len is 0. diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index e5ce784be..4e1ece0e9 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -676,12 +676,14 @@ export function requireExactArityValue( : null; if (mapped && typeEquals(mapped, funcType)) return; // An 'any' slot (`C.prototype.m = function (a, b) {…}` through an - // untyped receiver) boxes the value into a checked-dynamic function: + // untyped receiver), or a slot whose type maps to a checked-dynamic + // value (a member of a literal lowered with a generic function's type + // parameters erased), boxes the value into a checked-dynamic function: // the box completes omitted arguments with undefined and checks every // argument into the completed ABI, so any boxable signature passes. if ( target !== undefined && - (target.flags & ts.TypeFlags.Any) !== 0 && + ((target.flags & ts.TypeFlags.Any) !== 0 || mapped?.kind === "dyn") && canBoxFuncIntoDyn( funcType, (id) => lowerer.shapes.get(id), diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts index a07741461..645ff8d2c 100644 --- a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -1,6 +1,16 @@ import { varRef } from "../../ir/build.js"; -import { BOOL, CAUGHT, DYN, type IrFunction, type IrType } from "../../ir/ir.js"; +import { + BOOL, + CAUGHT, + DYN, + type IrExpr, + type IrFunction, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { classInstanceOf } from "./class-dynamic-dispatch.js"; +import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; /** Exception snapshots retain native objects. Recover their known layouts @@ -10,6 +20,10 @@ export class CaughtValueDispatch { private readonly thrownClasses = new Set(); private readonly convertedClasses = new Set(); private helper: IrFunction | null = null; + /** The dynamic-payload helpers of class tests and extractions, by kind + * and class (dynamicPayloads). */ + private readonly payloadHelpers = new Map(); + private readonly payloadHelperFns = new Set(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { const consumers: IrFunction[] = []; @@ -81,4 +95,164 @@ export class CaughtValueDispatch { } return changed; } + + /** A thrown unknown or union value, and the runtime's own dynamic throws, + * reach a catch binding as a dynamic payload. When an instance of class C + * can be such a payload (C or a subclass of C crosses into unknown, the + * `boxed` classes), `e instanceof C`, the reads it narrows and `e as C` in + * `sites` also answer through the dynamic class test, as standalone classes + * already do. Programs that never box C keep the native payload test. */ + dynamicPayloads( + lowerer: Lowerer, + sites: ReadonlySet, + boxed: ReadonlySet, + ): boolean { + if (boxed.size === 0 || sites.size === 0) return false; + const reached = new Map(); + const reachable = (className: string | undefined): ClassInfo | null => { + if (className === undefined) return null; + let info = reached.get(className); + if (info === undefined) { + const found = lowerer.classes.get(className); + info = + found && + lowerer.inHierarchy(found) && + [...boxed].some((name) => name === className || lowerer.isSubclassOf(name, className)) + ? found + : null; + reached.set(className, info); + } + return info; + }; + let changed = false; + const qualifies = (expr: IrExpr): boolean => + expr.kind === "caughtTest" + ? expr.test === "instanceof" && reachable(expr.className) !== null + : (expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") && + expr.type.kind === "object" && + reachable(expr.type.className) !== null; + for (const fn of sites) { + // The conversions themselves test the native payload. + if (fn === this.helper || this.payloadHelperFns.has(fn)) continue; + const found = !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !qualifies(expr) }); + if (!found) continue; + fn.body = transformStmtList(fn.body, { + stmt: (stmt) => stmt, + expr: (expr) => { + if (expr.kind === "caughtTest" && expr.test === "instanceof") { + const info = reachable(expr.className); + if (!info) return expr; + changed = true; + const call: IrExpr = { + kind: "call", + callee: this.payloadHelper(lowerer, "instanceof", info, expr.loc).name, + args: [expr.value], + type: BOOL, + loc: expr.loc, + }; + return expr.negated + ? { kind: "unary", op: "!", operand: call, type: BOOL, loc: expr.loc } + : call; + } + if ( + (expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") && + expr.type.kind === "object" + ) { + const info = reachable(expr.type.className); + if (!info) return expr; + changed = true; + const kind = expr.kind === "caughtNarrow" ? "narrow" : "check"; + return { + kind: "call", + callee: this.payloadHelper(lowerer, kind, info, expr.loc).name, + args: [expr.value], + type: expr.type, + loc: expr.loc, + }; + } + return expr; + }, + }); + } + return changed; + } + + /** `%caught.instanceof:C`, `%caught.narrow:C` and `%caught.check:C`: the + * native payload test or extraction, and otherwise a value thrown as + * unknown tested against C and checked into C. A check of a payload that + * is not a C keeps caughtCheck's TypeError. */ + private payloadHelper( + lowerer: Lowerer, + kind: "instanceof" | "narrow" | "check", + info: ClassInfo, + loc: SrcLoc, + ): IrFunction { + const className = info.def.name; + const name = `%caught.${kind}:${className}`; + const existing = this.payloadHelpers.get(name); + if (existing) return existing; + const caught = (): IrExpr => varRef("caught", CAUGHT, loc); + const dynamic = (): IrExpr => ({ kind: "caughtToDyn", value: caught(), type: DYN, loc }); + const native: IrExpr = { + kind: "caughtTest", + value: caught(), + test: "instanceof", + className, + type: BOOL, + loc, + }; + // Only a value thrown as unknown can hold an instance besides a native + // payload, which the native test answers exactly. + const dynamicInstance = (): IrExpr => ({ + kind: "logical", + op: "&&", + left: { kind: "caughtTest", value: caught(), test: "dynamic", type: BOOL, loc }, + right: classInstanceOf(lowerer, dynamic(), info, loc), + type: BOOL, + loc, + }); + const type: IrType = { kind: "object", className }; + const returnType = kind === "instanceof" ? BOOL : type; + let value: IrExpr; + if (kind === "instanceof") { + value = { + kind: "ternary", + cond: native, + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: dynamicInstance(), + type: BOOL, + loc, + }; + } else if (kind === "narrow") { + value = { + kind: "ternary", + cond: native, + then: { kind: "caughtNarrow", value: caught(), type, loc }, + else_: lowerer.coerceToExpected(dynamic(), type), + type, + loc, + }; + } else { + value = { + kind: "ternary", + cond: dynamicInstance(), + then: lowerer.coerceToExpected(dynamic(), type), + else_: { kind: "caughtCheck", value: caught(), className, type, loc }, + type, + loc, + }; + } + const helper: IrFunction = { + name, + params: [{ localId: "caught", name: "caught", type: CAUGHT }], + locals: [{ id: "caught", name: "caught", type: CAUGHT, mutable: false }], + returnType, + body: [{ kind: "return", value, loc }], + loc, + }; + this.payloadHelpers.set(name, helper); + this.payloadHelperFns.add(helper); + lowerer.liftedFns.push(helper); + return helper; + } } diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts index cb980f0d0..0b807e450 100644 --- a/packages/compiler/src/frontend/lowering/class-descriptors.ts +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -177,7 +177,7 @@ export function lowerClassDataDescriptor( function descriptorGuard(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): string { const name = `%descriptorGuard:${info.def.name}`; - const existing = lowerer.liftedFns.find((fn) => fn.name === name); + const existing = lowerer.liftedFnNamed(name); const key = varRef("key", DYN, loc); const fields = new Set(); const collect = (owner: ClassInfo): void => { diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts index 1c5c84cdf..dfb8bd7a5 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts @@ -32,6 +32,9 @@ function context() { unions: new Map(), classes: new Map(), liftedFns, + liftedFnNamed: (name: string | undefined) => liftedFns.find((fn) => fn.name === name), + subclassLinks: 0, + constructedClassCount: () => -1, diags: [], prototypeMethodAccesses: new Map(), protocolDispatches: new Map(), diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index cb5c28444..7d00eb89e 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -68,7 +68,11 @@ import { isClassCallback } from "./class-callbacks.js"; import { CaughtValueDispatch } from "./caught-value-dispatch.js"; import { refreshDescriptorGuards } from "./class-descriptors.js"; import { ClassConstructionDispatch } from "./class-construction.js"; -import { emitterRooted } from "./lower-event-emitter.js"; +import { + emitterRooted, + lowerDynamicEmitterEmit, + lowerDynamicEmitterOn, +} from "./lower-event-emitter.js"; import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; import { STREAM_OPERATORS } from "./lower-stream.js"; import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; @@ -97,6 +101,24 @@ const EMITTER_METHODS = new Set([ "off", "removeListener", ]); +/** The rest of EventEmitter.prototype, answered through an untyped value + * on emitters that are not streams (streams keep their own surface). */ +const EMITTER_INTROSPECTION_METHODS = new Set([ + "listenerCount", + "eventNames", + "removeAllListeners", + "listeners", + "rawListeners", + "setMaxListeners", + "getMaxListeners", +]); +/** True when the class is or extends one of the runtime stream classes. */ +function streamRooted(info: ClassInfo): boolean { + for (let c: ClassInfo | null = info; c; c = c.base) { + if (c.builtinStream !== undefined) return true; + } + return false; +} const STREAM_BOOL_PROPERTIES = new Set([ "readable", "readableEnded", @@ -366,7 +388,7 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] { export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction { const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties"; - const existing = lowerer.liftedFns.find((fn) => fn.name === name); + const existing = lowerer.liftedFnNamed(name); if (existing) return existing; const helper: IrFunction = { name, @@ -762,6 +784,7 @@ export class ClassDynamicDispatch { const rewrite = new Set(); const toPrimitiveSites = new Set(); const hasKeySites = new Set(); + const caughtSites = new Set(); const discover = (type: IrType): void => { const key = typeKey(type); if (seenTypes.has(key)) return; @@ -827,6 +850,13 @@ export class ClassDynamicDispatch { case "dynHasKey": hasKeySites.add(fn); break; + case "caughtTest": + if (expr.test === "instanceof") caughtSites.add(fn); + break; + case "caughtNarrow": + case "caughtCheck": + if (expr.type.kind === "object") caughtSites.add(fn); + break; case "dynKeyGet": case "dynInvoke": rewrite.add(fn); @@ -865,6 +895,7 @@ export class ClassDynamicDispatch { return true; }, }); + changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed; if (this.boxed.size === 0 && rewrite.size === 0) return changed; const converting = this.convertingHints(lowerer); const calling = this.calledConversions(lowerer); @@ -1003,7 +1034,7 @@ export class ClassDynamicDispatch { const loc = this.propertyBag.loc; const method = classMethodValue(lowerer, info.decl, info, "toJSON", loc); const prototype = method ? classPrototypeData(lowerer, info, loc) : null; - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper); + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper); const init = helper?.body[0]; if ( !info.prototypeReflectionReady && @@ -1223,6 +1254,13 @@ export class ClassDynamicDispatch { const matching = this.propertyReceivers(lowerer, true).flatMap(({ info }) => { if (!info || info.fields.has(method) || info.builtinError) return []; if (emitterRooted(lowerer, info) && EMITTER_METHODS.has(method)) return [info]; + if ( + emitterRooted(lowerer, info) && + !streamRooted(info) && + EMITTER_INTROSPECTION_METHODS.has(method) && + !findMethodOn(lowerer, info, method) + ) + return [info]; if (info.builtinStream) { return STREAM_METHODS.has(method) || (STREAM_OPERATORS.has(method) && info.builtinStream !== "w") @@ -1236,6 +1274,15 @@ export class ClassDynamicDispatch { byMethod.set(method, matching); return matching; }; + const candidateSets = new Map>(); + const candidateSet = (method: string): ReadonlySet => { + let set = candidateSets.get(method); + if (!set) { + set = new Set(candidates(method)); + candidateSets.set(method, set); + } + return set; + }; for (const fn of functions) { // Discovery already visits every expression. Preserve bodies without // dispatch sites instead of rebuilding their entire typed IR tree on @@ -2220,16 +2267,16 @@ export class ClassDynamicDispatch { : expr.kind === "dynInvoke" ? expr : null; - const staticCandidates = - invoke && + // Static methods matter only for methods no instance answers. + const staticCandidates = (method: string): boolean => [...this.boxedConstructors].some((name) => { const info = lowerer.classes.get(name); - return info && findStaticOn(lowerer, info, invoke.method)?.method; + return info && findStaticOn(lowerer, info, method)?.method; }); if ( !invoke || (candidates(invoke.method).length === 0 && - !staticCandidates && + !staticCandidates(invoke.method) && !(invoke.method === "toString" && lowerer.classes.has("%Error"))) ) return expr; @@ -2501,10 +2548,13 @@ export class ClassDynamicDispatch { }); changed = true; } - for (const plan of this.propertyReceivers(lowerer, true)) { + // Compute the candidates where the first plan would have, so the + // memoized set is the same whichever plans are already answered. + const plans = this.propertyReceivers(lowerer, true); + const matching = plans.length ? candidateSet(dispatch.source.method) : null; + for (const plan of plans) { const { info } = plan; - if (!candidates(dispatch.source.method).includes(info) || dispatch.classes.has(plan.key)) - continue; + if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue; dispatch.classes.add(plan.key); const loc = dispatch.source.loc; const value = varRef("p.0", DYN, loc); @@ -2648,7 +2698,7 @@ export class ClassDynamicDispatch { if ( plan.info !== plan.capsule || dispatch.classes.has(plan.key) || - (!dispatch.field && !candidates(dispatch.method).includes(plan.info)) + (!dispatch.field && !candidateSet(dispatch.method).has(plan.info)) ) continue; dispatch.classes.add(plan.key); @@ -2738,6 +2788,8 @@ export class ClassDynamicDispatch { } for (const plan of this.propertyReceivers(lowerer, true)) { const { info } = plan; + // Classes answered in an earlier pass skip the member lookups. + if (dispatch.classes.has(plan.key)) continue; if ( !isClassOwnEnumerableFieldName(dispatch.name) || (!(info.builtinStream !== undefined && streamProperty(dispatch.name)) && @@ -2751,7 +2803,6 @@ export class ClassDynamicDispatch { !findMethodOn(lowerer, info, `set:${dispatch.name}`)) ) continue; - if (dispatch.classes.has(plan.key)) continue; dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); @@ -3310,7 +3361,7 @@ export class ClassDynamicDispatch { loc: SrcLoc, ): IrExpr { const name = fn === "dyn.hasKey" ? "%dyn.class.bagHas" : "%dyn.class.bagHasComputed"; - let helper = lowerer.liftedFns.find((item) => item.name === name); + let helper = lowerer.liftedFnNamed(name); if (!helper) { const keyType = fn === "dyn.hasKey" ? STRING : DYN; const value = varRef("p.0", DYN, loc); @@ -3907,7 +3958,7 @@ export class ClassDynamicDispatch { private readPropertyBagOrEmpty(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr { // One call per read site: large JS programs have a branch per class and // property, so the absent-bag handling lives in the shared helper. - let helper = lowerer.liftedFns.find((fn) => fn.name === READ_PROPERTIES_OR_EMPTY); + let helper = lowerer.liftedFnNamed(READ_PROPERTIES_OR_EMPTY); if (!helper) { // Settled by finalize; until then, the read-only bag's own dispatch. helper = { @@ -4256,17 +4307,38 @@ export class ClassDynamicDispatch { add(root); } + /** propertyReceivers results by mode, with the class graph they were + * computed from: the boxed classes, the registered classes, the subclass + * links and the constructed classes only grow, so equal counts and the + * same capsules give the same plans. */ + private readonly receiverPlans = new Map< + string, + { revision: string; capsules: (ClassInfo | undefined)[]; plans: readonly PropertyReceiver[] } + >(); + /** A capsule retains its static type, while its object may be a subclass. * Preorder insertion lets later branches test the most derived layout first. */ private propertyReceivers( lowerer: Lowerer, streams = false, normalize = true, - ): PropertyReceiver[] { + ): readonly PropertyReceiver[] { + const mode = `${streams}:${normalize}`; + const revision = `${this.boxed.size}:${lowerer.classes.size}:${lowerer.subclassLinks}:${lowerer.constructedClassCount()}`; + const cached = this.receiverPlans.get(mode); + if (cached?.revision === revision) { + let i = 0; + let same = true; + for (const name of this.boxed) + if (lowerer.classes.get(name) !== cached.capsules[i++]) same = false; + if (same) return cached.plans; + } + const capsules: (ClassInfo | undefined)[] = []; const plans: PropertyReceiver[] = []; const seen = new Set(); for (const name of this.boxed) { const capsule = lowerer.classes.get(name); + capsules.push(capsule); if ( !capsule || ((capsule.builtinEmitter || capsule.builtinStream) && !streams) || @@ -4275,10 +4347,10 @@ export class ClassDynamicDispatch { continue; const canonical = normalize && !capsule.builtinStream && !capsule.builtinEmitter; const visit = (info: ClassInfo): void => { - if ( - (!canonical || !seen.has(info.def.name)) && - (info === capsule || lowerer.classCanBeConstructed(info)) - ) { + // A seen class was visited with its whole subtree, and a canonical + // visit adds no plan for a seen class. + if (canonical && seen.has(info.def.name)) return; + if (info === capsule || lowerer.classCanBeConstructed(info)) { seen.add(info.def.name); plans.push({ info, @@ -4290,6 +4362,7 @@ export class ClassDynamicDispatch { }; visit(capsule); } + this.receiverPlans.set(mode, { revision, capsules, plans }); return plans; } @@ -5055,6 +5128,66 @@ export class ClassDynamicDispatch { return [store, { kind: "return", value: null, loc }]; } + /** EventEmitter.prototype's introspection and bookkeeping members on an + * emitter seen through an untyped value: Node's results over the native + * listener registry. */ + private emitterIntrospection(lowerer: Lowerer, dispatch: Dispatch, receiver: IrExpr): IrStmt[] { + const { method, loc } = dispatch.source; + const incoming = dispatch.source.args.map((_, i) => varRef(`p.${i + 1}`, DYN, loc)); + const ret = (value: IrExpr): IrStmt[] => [ + { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, + ]; + const call = (fn: IrLibFn, args: IrExpr[], type: IrType): IrExpr => ({ + kind: "libCall", + fn, + args: [receiver, ...args], + type, + loc, + }); + // Node indexes _events with the argument itself: an omitted name is + // the key "undefined". + const eventName = (): IrExpr => + incoming.length + ? lowerer.coerceToExpected(incoming[0]!, STRING) + : { kind: "strLit", value: "undefined", type: STRING, loc }; + switch (method) { + case "listenerCount": + return ret( + incoming.length >= 2 + ? call("emitter.countDyn", [eventName(), incoming[1]!], F64) + : call("emitter.count", [eventName()], F64), + ); + case "eventNames": + return ret(call("emitter.names", [], { kind: "array", elem: STRING })); + case "removeAllListeners": + return ret( + incoming.length + ? call( + "emitter.removeAll", + [eventName(), { kind: "boolLit", value: false, type: BOOL, loc }], + receiver.type, + ) + : call( + "emitter.removeAll", + [ + { kind: "strLit", value: "", type: STRING, loc }, + { kind: "boolLit", value: true, type: BOOL, loc }, + ], + receiver.type, + ), + ); + case "listeners": + case "rawListeners": + return ret(call("emitter.listenersDyn", [eventName()], DYN)); + case "setMaxListeners": + return ret( + call("emitter.setMaxChk", [incoming[0] ?? dynUndefinedExpr(loc)], receiver.type), + ); + default: + return ret(call("emitter.getMax", [], F64)); + } + } + private methodBody( lowerer: Lowerer, dispatch: Dispatch, @@ -5070,6 +5203,14 @@ export class ClassDynamicDispatch { loc, }, ]; + if ( + emitterRooted(lowerer, info) && + !streamRooted(info) && + EMITTER_INTROSPECTION_METHODS.has(method) && + !findMethodOn(lowerer, info, method) + ) { + return this.emitterIntrospection(lowerer, dispatch, receiver); + } if ( info.builtinStream || (emitterRooted(lowerer, info) && @@ -5090,18 +5231,37 @@ export class ClassDynamicDispatch { ]; if (method === "emit") { if (!incoming.length) return fence(); - return ret({ - kind: "libCall", - fn: "emitter.emitFlex", - args: [receiver, lowerer.coerceToExpected(incoming[0]!, STRING), ...incoming.slice(1)], - type: BOOL, - loc, - }); + const event = lowerer.coerceToExpected(incoming[0]!, STRING); + const bridged = streamRooted(info) + ? null + : lowerDynamicEmitterEmit(lowerer, receiver, event, incoming.slice(1), loc); + return ret( + bridged ?? { + kind: "libCall", + fn: "emitter.emitAny", + args: [receiver, event, ...incoming.slice(1)], + type: BOOL, + loc, + }, + ); } if (registering || removing) { if (incoming.length < 2) return fence(); const event = lowerer.coerceToExpected(incoming[0]!, STRING); const cb = incoming[1]!; + const bridged = + registering && !streamRooted(info) + ? lowerDynamicEmitterOn( + lowerer, + receiver, + event, + cb, + method === "once" || method === "prependOnceListener", + method.startsWith("prepend"), + loc, + ) + : null; + if (bridged) return ret(bridged); return [ { kind: "exprStmt", @@ -5113,7 +5273,7 @@ export class ClassDynamicDispatch { fn: registering ? info.builtinStream ? "stream.onDyn" - : "emitter.onFlex" + : "emitter.onAny" : "emitter.offDyn", args: [ receiver, diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 1197d9bdd..6e8256513 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -426,7 +426,7 @@ function reflectedMethodValue( ); } if (value.kind === "closure" && value.captures.length === 0) { - const body = lowerer.liftedFns.find((fn) => fn.name === value.fnName); + const body = lowerer.liftedFnNamed(value.fnName); if (body?.captures?.length === 0) delete body.captures; } const cache = staticMethod diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 7b4b58ae3..d2919e202 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -172,7 +172,7 @@ export function classPrototypeData( } if (!metadataOnly && !info.prototypeObservedMethodsReady) { info.prototypeObservedMethodsReady = true; - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper)!; + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper)!; const value = varRef(`%g.${info.def.prototypeDataHelper}`, DYN, loc); // Materialize only observed method slots. Own declarations stop lookup // at the correct prototype even if an ancestor is replaced later. An @@ -307,7 +307,7 @@ export function reflectClassPrototype( if (info.base.prototypeReflectionRefusal) return refuse("prototype reflection with this base class is not supported yet"); } - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper); + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper); const initialization = helper?.body[0]; if (!helper || initialization?.kind !== "if" || info.prototypeReflectionReady) return prototype; info.prototypeReflectionReady = true; diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 83a184400..40e4d6436 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1,6 +1,7 @@ import { generatorDrain } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { liftedFreshSpread } from "../coercions/fresh-spread.js"; +import { checkedIterableSpread } from "../checked-iterable-spread.js"; import { InternalCompilerError } from "../../../errors.js"; import { dynUndefinedExpr, @@ -25,6 +26,7 @@ import { STRING, type SrcLoc, arrayOf, + canConvertToDyn, funcOf, isRefCounted, isSupportedArrayElem, @@ -153,6 +155,32 @@ export function lowerArraySpreadItems( // Fresh elements (an array literal) convert into the element layout. source = liftedFreshSpread(lowerer, node.expression, source, arrType, loc) ?? source; } + // A checked-dynamic source, or any other iterable spread into + // checked-dynamic items, drains once through the iterator protocol + // (with the spread call's TypeErrors), and its elements convert to + // the element type. + const drains = + source.type.kind === "dyn" + ? elem.kind === "dyn" || + elem.kind === "f64" || + elem.kind === "string" || + elem.kind === "bool" + : elem.kind === "dyn" && + !typeEquals(source.type, arrType) && + !( + source.type.kind === "array" && + identityPreservingWidening(lowerer, source.type.elem, elem) + ) && + canConvertToDyn( + source.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ); + if (drains) { + const spelling = ts.getTextOfNode(node.expression); + const items = checkedIterableSpread(lowerer, source, spelling, loc); + source = lowerer.coerceInto(node.expression, items, arrType); + } if (!typeEquals(source.type, arrType)) { if ( source.type.kind !== "array" || diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 8d1d17821..438c5e6c6 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -38,7 +38,7 @@ import { lowerOptionalArgument, lowerStaticallyUndefinedArgument, } from "../optional-arguments.js"; -import { lowerCheckedArrayFrom } from "./array-construction.js"; +import { lowerArraySpreadItems, lowerCheckedArrayFrom } from "./array-construction.js"; import { lowerToNumberArgument } from "../lower-exprs.js"; import { lowerBytesIteratorValue } from "../iterator-adapters.js"; import { lowerArrayCallback } from "./callback-arguments.js"; @@ -642,7 +642,7 @@ export function lowerBytesNew( ); } -/** TypedArray.from without a mapping callback. Preserve its iterable / +/** TypedArray.from and TypedArray.of. Preserve from's iterable / * array-like semantics separately from constructor length coercion. */ export function lowerBytesStaticCall( lowerer: Lowerer, @@ -662,10 +662,15 @@ export function lowerBytesStaticCall( loc: locOf(call), }; } - if (access.name.text !== "from" || !ts.isIdentifier(access.expression)) return null; + if ( + (access.name.text !== "from" && access.name.text !== "of") || + !ts.isIdentifier(access.expression) + ) + return null; const symbol = lowerer.resolveValueSymbol(access.expression); const elem = symbol ? own(BYTES_CTORS, ts.symbolName(symbol)) : undefined; if (!elem || !lowerer.isStdlibSymbol(symbol ?? undefined)) return null; + if (access.name.text === "of") return lowerBytesOf(lowerer, call, elem); if ( call.arguments.nodes.length < 1 || call.arguments.nodes.length > 3 || @@ -754,6 +759,59 @@ export function lowerBytesStaticCall( }; } +/** TypedArray.of: a new array of the arguments, each converted with ToNumber + * in argument order after all of them are evaluated. Numbers copy directly; + * other values (and spread items) collect into an array that goes through + * TypedArray.from's conversion, which calls valueOf/toString the same way. */ +function lowerBytesOf(lowerer: Lowerer, call: ts.CallExpression, elem: IrBytesElem): IrExpr { + const loc = locOf(call); + const nodes = call.arguments.nodes; + const type = bytesOf(elem); + if (nodes.some(ts.isSpreadElement)) { + const numeric = nodes.every((node) => { + const mapped = lowerer.mapTypeOf( + lowerer.typeOf(ts.isSpreadElement(node) ? node.expression : node), + ); + return ts.isSpreadElement(node) + ? mapped?.kind === "array" && mapped.elem.kind === "f64" + : mapped?.kind === "f64"; + }); + const elemType = numeric ? F64 : DYN; + const items = lowerArraySpreadItems( + lowerer, + nodes, + elemType, + { kind: "array", elem: elemType }, + loc, + ); + if (numeric) return { kind: "bytesNew", source: items, type, loc }; + return { + kind: "bytesNew", + source: lowerer.coerceToExpected(items, DYN), + from: true, + type, + loc, + }; + } + const values = nodes.map((node) => lowerer.lowerExpr(node)); + if (values.every((value) => value.type.kind === "f64")) { + return { + kind: "bytesNew", + source: { kind: "arrayLit", elems: values, type: arrayOf(F64), loc }, + type, + loc, + }; + } + const elems = values.map((value, i) => lowerer.coerceInto(nodes[i]!, value, DYN)); + return { + kind: "bytesNew", + source: lowerer.coerceToExpected({ kind: "arrayLit", elems, type: arrayOf(DYN), loc }, DYN), + from: true, + type, + loc, + }; +} + /** Construct a view over the complete branded backing allocation. */ function lowerArrayBufferView( lowerer: Lowerer, @@ -1479,6 +1537,8 @@ function lowerBufferInstanceMethod( if (encName === "utf16le") align = 2; } else if (vT?.kind === "bytes" && vT.elem === "u8") { needle = lowerer.lowerExpr(args[0]!); + // A Buffer needle under utf16le also searches whole 2-byte units. + if (encName === "utf16le") align = 2; } else { lowerer.noLowering( `.${name} of '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}' values`, diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts index 01f632cf3..de16e5d61 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { observeRecordLiteralRebuilds, transformExpr } from "../../ir/traverse.js"; import { ShapeRegistry } from "../type-mapper.js"; import { applyKeyOrderWitnesses, @@ -156,7 +157,24 @@ test("a synthesized construction pins today's table", () => { expect(f.order(ab)).toEqual(["a", "b"]); }); -test("a checked conversion pins today's table", () => { +test("a checked conversion alone pins today's table", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", OPT], + ["b", F64], + ], + ["a", "b"], + ); + const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc }; + // A late write to `a` would move it after `b`; values only a checked + // conversion builds keep today's table, so parsed values in that order + // need no source view. + f.run([decl("p", parsed), set(ref("p", ab), "a"), observe(ref("p", ab))]); + expect(f.order(ab)).toEqual(["a", "b"]); +}); + +test("a shape literals and a checked conversion both build takes the literals' order", () => { const f = fixture(); const ab = f.record( [ @@ -166,8 +184,33 @@ test("a checked conversion pins today's table", () => { ["a", "b"], ); const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc }; + // The literal's values can enumerate in no other order; parsed values in + // another order keep a record-source view. f.run([observe(f.literal(ab, ["b", "a"])), observe(parsed)]); - expect(f.order(ab)).toEqual(["a", "b"]); + expect(f.order(ab)).toEqual(["b", "a"]); +}); + +test("a literal an IR rewrite rebuilds keeps its witness", () => { + const f = fixture(); + const ab = f.record( + [ + ["a", F64], + ["b", F64], + ], + ["a", "b"], + ); + const identity = { expr: (e: IrExpr): IrExpr => e, stmt: (st: IrStmt): IrStmt => st }; + const original = f.literal(ab, ["b", "a"]); + observeRecordLiteralRebuilds((from, to) => f.notes.follow(from, to)); + let rebuilt: IrExpr; + try { + rebuilt = transformExpr(original, identity); + } finally { + observeRecordLiteralRebuilds(null); + } + expect(rebuilt).not.toBe(original); + f.run([observe(rebuilt)]); + expect(f.order(ab)).toEqual(["b", "a"]); }); test("a spread contributes its source's order, and an override keeps its position", () => { diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts index 437a29563..2fc9a6cbd 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts @@ -23,10 +23,12 @@ * - any other construction (synthesized builtin results, opaque runtime * producers) pins today's table; * - checked conversions from dynamic values (typed JSON.parse, values - * from JavaScript) pin today's table: the record-source table keeps - * their own key order relative to it, and another table would make - * values parsed in today's order keep a source view. Their order is - * unknown, so a copy out of such a shape cannot be ordered statically; + * from JavaScript) contribute nothing: the record-source table keeps + * their own key order relative to the chosen table. A shape only they + * build keeps today's table, since another table would make values + * parsed in today's order keep a source view; a shape object literals + * also build takes the literals' order. Their order is unknown, so a + * copy out of such a shape cannot be ordered statically; * - a write to an optional key that may be absent appends it in Node. A * key no construction creates ("late-only") goes after every key a * construction can create; any other moving write goes after every key. @@ -56,8 +58,10 @@ export type KeyOrderSegment = | { kind: "opaque" }; /** Witness notes the lowering attaches to the IR nodes it builds. Keyed by - * node identity, so nodes a speculative lowering discards are never walked, - * and a node some later pass rebuilds falls back to pinning. */ + * node identity, so nodes a speculative lowering discards are never walked. + * Literals the IR rewriters rebuild (traverse.ts mapExprChildren) carry + * their notes over (follow); a node rebuilt any other way falls back to + * pinning. */ export class KeyOrderNotes { private readonly literals = new Map(); private readonly copies = new Map(); @@ -77,6 +81,15 @@ export class KeyOrderNotes { return node; } + /** A pass rebuilt `from` as `to` (traverse.ts mapExprChildren): the + * rebuilt literal is the same construction, so it keeps the witness. */ + follow(from: IrExpr, to: IrExpr): void { + const segments = this.literals.get(from); + if (segments !== undefined) this.literals.set(to, segments); + const source = this.copies.get(from); + if (source !== undefined) this.copies.set(to, source); + } + observe(type: IrType): void { if (type.kind === "record") this.observed.add(type.shapeId); } @@ -388,8 +401,13 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio // A checked conversion builds values in their source's own order, which // the record-source table reproduces relative to the shape's table. A new // table would make every value parsed in today's order keep a source - // view, so such shapes keep today's table. - for (const f of facts.values()) if (f.runtime) f.pinned = true; + // view, so a shape only checked conversions build keeps today's table. + // A shape the program also builds with object literals takes their order: + // the literal's values have no other way to enumerate in it, and values + // parsed from JSON those literals wrote (a message sent to a child or + // another service of the program) arrive in it too. Parsed values in any + // other order keep a source view, exactly as before. + for (const f of facts.values()) if (f.runtime && f.literals.length === 0) f.pinned = true; // Nothing to choose: every witness agrees with today's table and no // optional key is written late. diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1f368e07f..e367cec92 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4098,6 +4098,12 @@ const DYN_DISPATCH_METHODS = new Set([ "prependOnceListener", "listeners", "listenerCount", + // The rest of EventEmitter.prototype, which compiled emitters answer + // through the class dispatch (class-dynamic-dispatch.ts). + "rawListeners", + "eventNames", + "setMaxListeners", + "getMaxListeners", "write", "end", "destroy", @@ -8079,6 +8085,14 @@ function lowerObjectStaticCall( loc, }; } + const fnIntegrity = functionIntegrity( + lowerer, + member, + value, + call.arguments.nodes[0]!, + locOf(call), + ); + if (fnIntegrity) return fnIntegrity; if (member === "preventExtensions" || member === "seal") { const integrity = lowerClassIntegrity(lowerer, call, member, value); if (integrity) return integrity; @@ -8162,6 +8176,8 @@ function lowerObjectStaticCall( ) ) return value; + const fnIntegrity = functionIntegrity(lowerer, "freeze", value, argNode, locOf(call)); + if (fnIntegrity) return fnIntegrity; const integrity = lowerClassIntegrity(lowerer, call, "freeze", value); if (integrity) return integrity; lowerer.noLowering( @@ -10647,3 +10663,55 @@ function lowerObjectAssignOntoError(lowerer: Lowerer, call: ts.CallExpression): loc, }; } + +/** `Object.freeze(fn)` and the other integrity operations on a function + * value: the function's checked-dynamic face holds its own properties (the + * ones a program assigns, `name`, `length`, `prototype`) and their + * integrity state, which the runtime keeps on its property table. The + * mutators answer the function itself; the queries answer from the table. + * Null for other values. */ +function functionIntegrity( + lowerer: Lowerer, + member: string, + value: IrExpr, + node: ts.Expression, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "func" || !lowerer.dynConvertible(value.type)) return null; + const operations = { + freeze: "dyn.freeze", + seal: "dyn.seal", + preventExtensions: "dyn.preventExtensions", + isSealed: "dyn.isSealed", + isExtensible: "dyn.isExtensible", + } as const; + if (!(member in operations)) return null; + const fn = operations[member as keyof typeof operations]; + // The first operation on a function creates its property table, whose + // `name` comes from the box: give the box the function's JS name, as a + // closure flowing into any checked-dynamic slot gets. + const name = jsFuncNameOf(node, lowerer); + const boxed = (e: IrExpr): IrExpr => { + const box = lowerer.coerceToExpected(e, DYN); + return box.kind === "dynFrom" && name !== null ? { ...box, fnName: name } : box; + }; + if (fn === "dyn.isSealed" || fn === "dyn.isExtensible") { + return { kind: "libCall", fn, args: [boxed(value)], type: BOOL, loc }; + } + const held = lowerer.declareHiddenLocal("%integrityFn", value.type); + const ref = (): IrExpr => ({ kind: "varRef", localId: held.id, type: value.type, loc }); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: held.id, init: value, loc }, + { + kind: "exprStmt", + expr: { kind: "libCall", fn, args: [boxed(ref())], type: DYN, loc }, + loc, + }, + ], + result: ref(), + type: value.type, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 371541df8..f312e9229 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -946,7 +946,10 @@ export function registerBuiltinErrorClasses(lowerer: Lowerer): void { throwingSetters: [], staticFields: [], }; - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(irName, info); } } @@ -1026,7 +1029,10 @@ export function registerBuiltinStreamClasses(lowerer: Lowerer): void { throwingSetters: [], staticFields: [], }; - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(irName, info); } } @@ -4471,7 +4477,10 @@ export function collectClassShapeInner( }; lowerer.genericClassByDecl.set(decl, info.generic); } - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(className, info); if (computedBase && !functionLocalClass(decl)) { info.def.baseValueGlobal = `%g.${className}.computedBase`; diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f8588fe48..d84f84f4e 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -53,6 +53,8 @@ import { BOOL, canBoxFuncIntoDyn, canConvertToDyn, + canDynCheckTo, + isDynTypedRefType, DYN, F64, type IrExpr, @@ -69,7 +71,7 @@ import { VOID, } from "../../ir/ir.js"; import { STREAM_FORCED_EVENT_NAMES, streamForcedTuple, streamSidesOf } from "./lower-stream.js"; -import { boolLit, strLit, varRef } from "../../ir/build.js"; +import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerAddAbortListener } from "./builtin-calls.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; @@ -336,9 +338,15 @@ function emitterEvents(lowerer: Lowerer): Map { const cbCt = lowerer.typeOf(node.arguments.nodes[1]); const cbT = lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, node.arguments.nodes[1], cbCt); + // A TypeScript listener with no parameters is typed too: it + // registers without the adapter, so an untyped emit reaches it + // only through the event's fixed ABI. A JavaScript function + // without parameters can still read `arguments`. if ( cbT?.kind !== "dyn" && - (cbT?.kind !== "func" || cbT.params.some((p) => p.kind !== "dyn")) + (cbT?.kind !== "func" || + (cbT.params.length === 0 && !isJsSourceFile(sf)) || + cbT.params.some((p) => p.kind !== "dyn")) ) { sigOf(name).typedListener = true; } @@ -931,6 +939,239 @@ function lowerDynListenerCall( }; } +/** The literal events typed code dispatches with a fixed tuple — it emits + * them or listens with typed parameters — whose names an untyped receiver + * may also use. 'error' and the meta events are excluded: the runtime fixes + * their tuples itself (scr_emitter_on_flex / scr_emitter_emit_flex). */ +function bridgedEvents(lowerer: Lowerer): { name: string; tuple: IrType[] }[] { + const table = emitterEvents(lowerer); + const events: { name: string; tuple: IrType[] }[] = []; + for (const [key, sig] of table) { + if (key === "error" || key === ERROR_MONITOR_EVENT || META_EVENTS.has(key)) continue; + if (sig.conflict !== null || (!sig.fromEmit && sig.typedListener !== true)) continue; + if (flexibleJsEvent(key, sig)) continue; + if (computedPatterns(lowerer).some((pattern) => patternMatchesName(pattern, key))) continue; + events.push({ name: eventKeyName(key), tuple: sig.tuple }); + } + return events; +} + +/** `emit(name, ...args)` through an untyped value on a (non-stream) + * emitter-rooted receiver, when typed code fixes the tuple of some event. + * Listeners registered as checked-dynamic functions receive the arguments + * as given; when the name is such an event and a listener with typed + * parameters is registered, the arguments also convert to the event's tuple + * (a mismatch throws the checked conversion's TypeError). Null when no event + * needs this, so the caller keeps the plain untyped emit. */ +export function lowerDynamicEmitterEmit( + lowerer: Lowerer, + receiver: IrExpr, + name: IrExpr, + payload: IrExpr[], + loc: SrcLoc, +): IrExpr | null { + const recvT = receiver.type; + if (recvT.kind !== "object") return null; + const k = payload.length; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + const checkable = (t: IrType): boolean => + t.kind === "dyn" || + isDynTypedRefType(t) || + isUnitType(t) || + (t.kind === "union" + ? (getUnion(t.unionId)?.arms.every(checkable) ?? false) + : canDynCheckTo(t, getRecord, getUnion)); + const converted = (tuple: IrType[]): IrExpr[] | null => { + if (!tuple.every(checkable)) return null; + const out: IrExpr[] = []; + for (let i = 0; i < tuple.length; i++) { + const arg = i < k ? varRef(`a.${i}`, DYN, loc) : dynUndefinedExpr(loc); + const value = lowerer.coerceToExpected(arg, tuple[i]!); + if (!typeEquals(value.type, tuple[i]!)) return null; + out.push(value); + } + return out; + }; + const events = bridgedEvents(lowerer) + .map((event) => ({ ...event, args: converted(event.tuple) })) + .filter((event) => event.args !== null); + if (events.length === 0) return null; + const key = `emitter.dynEmit:${typeKey(recvT)}:${k}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%emitter.dynEmit.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const params: IrParam[] = [ + { localId: "r.0", name: "r", type: recvT }, + { localId: "n.0", name: "n", type: STRING }, + ...payload.map((_, i) => ({ localId: `a.${i}`, name: `a${i}`, type: DYN })), + ]; + const r = varRef("r.0", recvT, loc); + const n = varRef("n.0", STRING, loc); + const dynArgs = payload.map((_, i) => varRef(`a.${i}`, DYN, loc)); + const body: IrStmt[] = events.map((event): IrStmt => ({ + kind: "if", + cond: { + kind: "strEq", + negated: false, + left: n, + right: strLit(event.name, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "if", + cond: { kind: "libCall", fn: "emitter.hasTyped", args: [r, n], type: BOOL, loc }, + then: [ + { + kind: "return", + value: { + kind: "libCall", + fn: "emitter.emitMixed", + args: [ + r, + n, + { kind: "numLit", value: k, type: F64, loc }, + ...dynArgs, + ...event.args!, + ], + type: BOOL, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + ], + else_: null, + loc, + })); + body.push({ + kind: "return", + value: { kind: "libCall", fn: "emitter.emitAny", args: [r, n, ...dynArgs], type: BOOL, loc }, + loc, + }); + lowerer.liftedFns.push({ + name: helper, + params, + returnType: BOOL, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [receiver, name, ...payload], type: BOOL, loc }; +} + +/** on/once/prepend*(name, fn) through an untyped value on a (non-stream) + * emitter-rooted receiver, when typed code fixes the tuple of some event. + * Under such an event's name the function registers with a typed adapter + * for typed emits as well as its checked-dynamic thunk for untyped ones; + * other names register the thunk alone. Null when no event needs this. */ +export function lowerDynamicEmitterOn( + lowerer: Lowerer, + receiver: IrExpr, + name: IrExpr, + cb: IrExpr, + once: boolean, + prepend: boolean, + loc: SrcLoc, +): IrExpr | null { + const recvT = receiver.type; + if (recvT.kind !== "object") return null; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + const events = bridgedEvents(lowerer).filter((event) => + event.tuple.every((t) => t.kind === "dyn" || canConvertToDyn(t, getRecord, getUnion)), + ); + if (events.length === 0) return null; + const key = `emitter.dynOn:${typeKey(recvT)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%emitter.dynOn.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const params: IrParam[] = [ + { localId: "r.0", name: "r", type: recvT }, + { localId: "n.0", name: "n", type: STRING }, + { localId: "cb.0", name: "cb", type: DYN }, + { localId: "o.0", name: "o", type: BOOL }, + { localId: "p.0", name: "p", type: BOOL }, + ]; + const locals: IrLocal[] = params.map((p) => ({ + id: p.localId, + name: p.name, + type: p.type, + mutable: false, + })); + const r = varRef("r.0", recvT, loc); + const n = varRef("n.0", STRING, loc); + const fn = varRef("cb.0", DYN, loc); + const o = varRef("o.0", BOOL, loc); + const p = varRef("p.0", BOOL, loc); + const body: IrStmt[] = [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "emitter.checkListener", args: [fn], type: VOID, loc }, + loc, + }, + ]; + events.forEach((event, i) => { + const adapterT: IrType = { kind: "func", params: event.tuple, ret: VOID }; + const adapter = `a.${i}`; + locals.push({ id: adapter, name: "a", type: adapterT, mutable: false }); + body.push({ + kind: "if", + cond: { + kind: "strEq", + negated: false, + left: n, + right: strLit(event.name, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "varDecl", + localId: adapter, + init: { kind: "dynCheck", value: fn, type: adapterT, loc }, + loc, + }, + { + kind: "return", + value: { + kind: "libCall", + fn: "emitter.onDyn", + args: [r, n, fn, varRef(adapter, adapterT, loc), o, p], + type: recvT, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }); + }); + body.push({ + kind: "return", + value: { kind: "libCall", fn: "emitter.onAny", args: [r, n, fn, o, p], type: recvT, loc }, + loc, + }); + lowerer.liftedFns.push({ name: helper, params, returnType: recvT, locals, body, loc }); + } + return { + kind: "call", + callee: helper, + args: [receiver, name, cb, boolLit(once, loc), boolLit(prepend, loc)], + type: recvT, + loc, + }; +} + /** A listener argument: lowered, checked void-returning, and its * parameters checked as a typeEquals PREFIX of the event tuple. */ function lowerListenerArg( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cdb738617..ebd0a4ca0 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -35,6 +35,7 @@ import { import { identityPreservingWidening } from "./coercions/identity.js"; import { scanDivergences } from "./divergences.js"; import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js"; +import { observeRecordLiteralRebuilds } from "../../ir/traverse.js"; import { checkedClassAssertion } from "./class-assertions.js"; import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js"; import { @@ -210,6 +211,7 @@ import type { IrLocal, IrModule, IrWasmModule, + IrWasmUntranslated, IrParam, IrRecordShape, IrStmt, @@ -1210,17 +1212,28 @@ export function lowerToIr( }); // A program that uses the WebAssembly API carries every module the build // can see and translate (wasm/translate.ts); the rest fail at run time - // with the CompileError for untranslated bytes. + // with a CompileError, which names the reason for the modules the build + // saw but does not translate. if (result.module !== null && resultLowerer.usesWebAssembly) { const bytesByHash = new Map(); const reports = discoverModules((sha256, bytes) => bytesByHash.set(sha256, bytes)); const wasmModules: IrWasmModule[] = []; + const wasmUntranslated: IrWasmUntranslated[] = []; for (const report of reports) { const bytes = bytesByHash.get(report.sha256); - if (report.status !== "valid" || bytes === undefined) continue; - wasmModules.push({ sha256: report.sha256, bytes: encodeBase64(bytes) }); + if (bytes === undefined) continue; + if (report.status === "valid") { + wasmModules.push({ sha256: report.sha256, bytes: encodeBase64(bytes) }); + continue; + } + const reason = + report.status === "unsupported" + ? `it uses ${report.unsupported.join(", ")}, outside the translated WebAssembly feature set` + : `it is not a valid module: ${report.error ?? report.status}`; + wasmUntranslated.push({ sha256: report.sha256, size: bytes.length, reason }); } result.module.wasmModules = wasmModules; + result.module.wasmUntranslated = wasmUntranslated; if (resultLowerer.usesWasmCustomSections) result.module.wasmCustomSections = true; timing("wasm-discovery", { modules: wasmModules.length }); } @@ -3045,6 +3058,10 @@ export class Lowerer { // top level, top = the innermost nested function currently lowering). fnStack: FnCtx[] = []; readonly liftedFns: IrFunction[] = []; + /** The first lifted function of each name (liftedFnNamed), for the + * liftedFns indexed so far. liftedFns only grows. */ + private readonly liftedFnsByName = new Map(); + private liftedFnsIndexed = 0; /** Runtime-provided classes used as values (defineClassValueConstruct), * by IR class name. */ readonly runtimeClassValues = new Map(); @@ -3116,6 +3133,10 @@ export class Lowerer { * can contain any retained descendant, but importing its declaration * alone does not make every descendant constructible. */ private constructedClasses: Set | null = null; + /** Classes added to some ClassInfo's subclasses (lower-classes.ts). With + * the class and constructed-class counts, it tells caches over the class + * graph when the graph changed. */ + subclassLinks = 0; /** Set while a subclass constructor's super() call notes its base * constructor edge: that edge does not instantiate the base exactly. */ private superConstructorEdge = false; @@ -3132,6 +3153,20 @@ export class Lowerer { info.mixinInstance !== undefined ); } + /** How many classes classCanBeConstructed admits by name, or -1 while + * it admits every class. The count only grows. */ + constructedClassCount(): number { + return this.constructedClasses?.size ?? -1; + } + /** The first lifted function named `name`, the one + * `liftedFns.find((fn) => fn.name === name)` returns. */ + liftedFnNamed(name: string | undefined): IrFunction | undefined { + for (; this.liftedFnsIndexed < this.liftedFns.length; this.liftedFnsIndexed++) { + const fn = this.liftedFns[this.liftedFnsIndexed]!; + if (!this.liftedFnsByName.has(fn.name)) this.liftedFnsByName.set(fn.name, fn); + } + return name === undefined ? undefined : this.liftedFnsByName.get(name); + } /** Coverage remainder mode: the reachability gate inverts (see wantBody) * and no module is built. */ readonly remainder: boolean; @@ -7411,6 +7446,10 @@ export class Lowerer { * body; both fire edges through the same hooks and are not units * themselves. */ emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } { + // Frontend passes below rewrite function bodies through the IR mapper + // (caught-value and class dispatch); key-order notes follow the record + // literals they rebuild until the witness pass has read them. + observeRecordLiteralRebuilds((from, to) => this.keyOrder.follow(from, to)); this.deferredModuleInitializers = new DeferredModuleInitializers(); const parts = this.splitFiles(); this.narrowing.setFiles(parts.map((fp) => fp.sf)); @@ -7841,6 +7880,7 @@ export class Lowerer { ...this.liftedFns, ...this.implicitFns, ]); + observeRecordLiteralRebuilds(null); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); const functions = [ ...orderedUnits, diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-records.ts b/packages/compiler/src/frontend/lowering/runtime-optional-records.ts index 18e136d69..7f1bb046a 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-records.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-records.ts @@ -81,7 +81,9 @@ export class RecordFlowClasses { /** Identifiers by name; symbols resolve only for the names of slots and * functions, so the scan asks the checker about few nodes. */ private readonly names = new Map(); - private readonly references = new Map(); + /** The identifiers of each name grouped by the symbol they resolve to, + * built on the first reference query for that name. */ + private readonly references = new Map>(); /** Names written as shorthand properties (`{ p }`): the checker resolves * those to the property, so a binding of that name stays open. */ private readonly shorthandNames = new Set(); @@ -349,14 +351,20 @@ export class RecordFlowClasses { /** The identifiers that name `symbol`, its declaration included. */ private referencesOf(symbol: ts.Symbol): readonly ts.Identifier[] { - let list = this.references.get(symbol); - if (!list) { - list = (this.names.get(ts.symbolName(symbol)) ?? []).filter( - (node) => this.host.symbolOf(node) === symbol, - ); - this.references.set(symbol, list); + const name = ts.symbolName(symbol); + let bySymbol = this.references.get(name); + if (!bySymbol) { + bySymbol = new Map(); + for (const node of this.names.get(name) ?? []) { + const resolved = this.host.symbolOf(node); + if (!resolved) continue; + const list = bySymbol.get(resolved); + if (list) list.push(node); + else bySymbol.set(resolved, [node]); + } + this.references.set(name, bySymbol); } - return list; + return bySymbol.get(symbol) ?? []; } private collectFunctions(): void { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4e08ba401..c77938337 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2104,6 +2104,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< glob: ["fsp.globTypes"], // The Uint8Array/Buffer-data form. appendFile: ["fsp.appendFileBytes"], + // The options forms. + rm: ["fsp.rmOpts", "fsp.rmRetry"], }, crypto: { // The composed randomBytes(n).toString(enc) chain keeps its one-libCall diff --git a/packages/compiler/src/frontend/npm-static-tsc-helpers.test.ts b/packages/compiler/src/frontend/npm-static-tsc-helpers.test.ts new file mode 100644 index 000000000..735c9d298 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-tsc-helpers.test.ts @@ -0,0 +1,71 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function rewrite(source: string): string | null { + return nodeFrontendServices().tscBuiltinImports(source, "/pkg/index.js"); +} + +const helpers = `"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +`; + +function expectSameBehavior(source: string): string { + const result = rewrite(source); + expect(typeof result).toBe("string"); + const dir = mkdtempSync(join(tmpdir(), "scriptc-tsc-builtin-imports-")); + dirs.push(dir); + writeFileSync(join(dir, "original.cjs"), source); + writeFileSync(join(dir, "rewritten.cjs"), result!); + const run = (file: string) => + spawnSync(process.execPath, [join(dir, file)], { encoding: "utf8" }); + const oracle = run("original.cjs"); + expect(oracle.status).toBe(0); + expect(run("rewritten.cjs").stdout).toBe(oracle.stdout); + return result!; +} + +test("builtin modules behind tsc's interop helpers become plain requires", () => { + const out = expectSameBehavior(`${helpers}const events_1 = __importDefault(require("events")); +const path = __importStar(require("node:path")); +class Bus extends events_1.default.EventEmitter {} +console.log(new Bus() instanceof events_1.default, path.basename("/a/b.txt"), typeof path.join); +`); + expect(out).toContain('const events_1 = require("events");'); + expect(out).toContain("class Bus extends events_1.EventEmitter {}"); + expect(out).toContain('const path = require("node:path");'); +}); + +test("other references, program modules and default reads of a namespace keep their text", () => { + // The binding itself as a value. + expect( + rewrite( + `${helpers}const events_1 = __importDefault(require("events"));\nconsole.log(events_1);\n`, + ), + ).toBeNull(); + // A program module (cjs-tsc-interop.ts owns it). + expect( + rewrite(`${helpers}const m = __importDefault(require("./m"));\nm.default();\n`), + ).toBeNull(); + // `ns.default` reads the namespace copy's own default. + expect( + rewrite(`${helpers}const p = __importStar(require("path"));\nconsole.log(p.default === p);\n`), + ).toBeNull(); +}); diff --git a/packages/compiler/src/frontend/npm-static-tsc-helpers.ts b/packages/compiler/src/frontend/npm-static-tsc-helpers.ts new file mode 100644 index 000000000..fcb73be5b --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-tsc-helpers.ts @@ -0,0 +1,106 @@ +/* tsc wraps every CommonJS import in its interop helpers: + * + * const events_1 = __importDefault(require("events")); // events_1.default.X + * const path = __importStar(require("path")); // path.basename + * + * For program modules cjs-tsc-interop.ts reads such a binding as a plain + * require binding, and the helper declarations (`var __importDefault = + * (this && this.__importDefault) || …`) go dead. A builtin module is not a + * program module, so its binding kept the helpers live, and their probe of + * the module's top-level `this` has no lowering. A builtin carries no + * `__esModule`: `events_1.default` is the module and the namespace copy + * reads the module's members. When every reference has that form + * (`x.default…` for the default helper, `x.` other than `default` + * for the namespace helper), the binding becomes the plain `require()` of + * the builtin and `x.default` becomes `x`. */ +import * as ts from "./ts7/syntax.js"; +import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js"; +import { canonicalBuiltinModule } from "./builtin-modules.js"; + +/** True when the text may hold such a binding (a cheap probe). */ +export function mayHoldTscBuiltinImport(source: string): boolean { + return /__import(?:Default|Star)\(require\(/.test(source); +} + +/** `__importDefault(require(""))` / `__importStar(…)`: the helper + * and builtin spec, or null. */ +function builtinInterop(init: ts.Expression): { helper: string; spec: string } | null { + const e = unparen(init); + if (!ts.isCallExpression(e) || !ts.isIdentifier(e.expression) || e.arguments.nodes.length !== 1) + return null; + const helper = e.expression.text; + if (helper !== "__importDefault" && helper !== "__importStar") return null; + const req = unparen(e.arguments.nodes[0]!); + if ( + !ts.isCallExpression(req) || + !ts.isIdentifier(req.expression) || + req.expression.text !== "require" || + req.arguments.nodes.length !== 1 || + !ts.isStringLiteral(req.arguments.nodes[0]!) + ) + return null; + const spec = (req.arguments.nodes[0] as ts.StringLiteral).text; + return canonicalBuiltinModule(spec) === null ? null : { helper, spec }; +} + +/** The `.default` accesses to remove when every reference to `name` (other + * than its declaration) has the helper's form, or null. */ +function interopReferences( + sf: ts.SourceFile, + decl: ts.VariableDeclaration, + name: string, + helper: string, +): ts.PropertyAccessExpression[] | null { + const defaults: ts.PropertyAccessExpression[] = []; + let ok = true; + ts.walkPreorder(sf, (n) => { + if (!ok) return "stop"; + if (!ts.isIdentifier(n) || n.text !== name || n === decl.name) return undefined; + const parent = n.parent; + if ( + parent === undefined || + !ts.isPropertyAccessExpression(parent) || + parent.expression !== n || + parent.questionDotToken !== undefined + ) { + ok = false; + return "stop"; + } + const member = parent.name.text; + if (helper === "__importDefault") { + if (member !== "default") ok = false; + else defaults.push(parent); + } else if (member === "default") { + ok = false; + } + return undefined; + }); + return ok ? defaults : null; +} + +/** The module text with its builtin interop bindings unwrapped, or null. */ +export function unwrapTscBuiltinImports(sf: ts.SourceFile): string | null { + if (ts.isExternalModule(sf) && !sf.fileName.endsWith(".cjs")) return null; + const edits: Edit[] = []; + const stmts = sf.statements.nodes; + // Builtin modules through the interop helpers. + for (const stmt of stmts) { + if (!ts.isVariableStatement(stmt)) continue; + for (const d of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(d.name) || d.initializer === undefined) continue; + const interop = builtinInterop(d.initializer); + if (interop === null) continue; + const defaults = interopReferences(sf, d, d.name.text, interop.helper); + if (defaults === null) continue; + edits.push({ + start: d.initializer.getStart(sf), + end: d.initializer.getEnd(), + text: `require(${JSON.stringify(interop.spec)})`, + }); + for (const access of defaults) + edits.push({ start: access.getStart(sf), end: access.getEnd(), text: d.name.text }); + } + } + if (edits.length === 0) return null; + return applyEdits(sf.text, 0, sf.text.length, edits); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 1bf84e734..bc9fc2abd 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -455,6 +455,9 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow // Polyfill packages that ARE a Node builtin compile as the builtin. const aliased = original === null ? null : services.builtinAliasRequires(original, path); if (aliased !== null) original = aliased; + // tsc's interop helpers around builtin modules. + const tscHelpers = original === null ? null : services.tscBuiltinImports(original, path); + if (tscHelpers !== null) original = tscHelpers; // A module exporting one of its functions reaches its members // through that function. const fnExports = original === null ? null : services.cjsFunctionExports(original, path); @@ -469,6 +472,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow if (exportsVar !== null) original = exportsVar; const respelled = split !== null || + tscHelpers !== null || esm !== null || aliased !== null || fnExports !== null || diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index be3881e13..10931ab29 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -13,6 +13,7 @@ import { rewriteBuiltinAliasRequires, } from "./npm-static-builtin-aliases.js"; import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js"; +import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js"; import { builtinPackageAliasFor } from "./package-shims.js"; import * as syntax from "./npm-static-declaration-syntax.js"; import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js"; @@ -106,6 +107,13 @@ export class FrontendServices { if (!source.includes("module.exports")) return null; return normalizeCjsFunctionExports(this.parse(path, source, "js")); } + /** A package module with tsc's interop bindings of builtin modules + * unwrapped to plain requires (null: none). */ + tscBuiltinImports(source: string, path: string): string | null { + this.ensureOpen(); + if (!mayHoldTscBuiltinImport(source)) return null; + return unwrapTscBuiltinImports(this.parse(path, source, "js")); + } /** A package module with its literal-valued TypeScript enum IIFEs * respelled as object literals (null: none). */ enumIifes(source: string, path: string): string | null { diff --git a/packages/compiler/src/frontend/ts7/ast-node.test.ts b/packages/compiler/src/frontend/ts7/ast-node.test.ts index b9b8f0e7b..60bfaa005 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.test.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.test.ts @@ -101,6 +101,8 @@ const cases: Record = { '/** @typedef {Object} Options\n * @property {string} name\n * @property {number} count\n */\n/** @type {Options} */ const options = { name: "x", count: 2 };', "missing.ts": "const incomplete = ;\nfunction f( {\n", "empty.ts": "", + "targets.ts": + "let x: number | undefined = 1;\nconst a = [1];\n(x) = 2;\nx! = 3;\na[0]! += 1;\n(a[0])!++;\n--(x!);\nfor ((x) of a) {}\nconst y = (x!) + 1;\n", "lists.ts": "const trailing = [1, 2 /* last */, ];\nconst plain = [1, 2];\nf(1, 2,);\nexport abstract class Flags { protected static readonly value = 1; }\n", }; @@ -167,6 +169,38 @@ function ids( return nodes?.map((n) => (n as OracleNode).index); } +test("the writeTargets option answers write targets without their wrappers", () => { + const { bytes, file: plain } = decoded.get("targets.ts")!; + const file = new AstFile(bytes, undefined, { writeTargets: true }); + const statements = (root: AstNode) => + (root.childList("statements") as AstNodeList).nodes.map((statement) => + statement.kind === SyntaxKind.ExpressionStatement ? statement.expression : statement, + ); + const text = (node: AstNode | undefined) => node?.getText(); + const writes = statements(file.root); + const raw = statements(plain.root); + // Assignment left sides, update operands and a for-of head. + const targets = [writes[2]!.left, writes[3]!.left, writes[4]!.left, writes[5]!.operand]; + expect(targets.map(text)).toEqual(["x", "x", "a[0]", "a[0]"]); + expect(text(writes[6]!.operand)).toBe("x"); + expect(text(writes[7]!.initializer)).toBe("x"); + for (const [index, target] of targets.entries()) + expect(target!.parent, `target ${index}`).toBe(writes[index + 2]); + expect(writes[7]!.initializer!.parent).toBe(writes[7]); + // The reference keeps its own source span. + expect(writes[4]!.left!.getStart()).toBe(raw[4]!.left!.getStart()); + expect(writes[4]!.left!.end).toBe(raw[4]!.left!.end - 1); + // A wrapper outside a write target is kept. + const init = (writes[8]!.childNode("declarationList")!.childList("declarations") as AstNodeList) + .nodes[0]!.initializer!; + expect(text(init.left)).toBe("(x!)"); + expect(init.left!.kind).toBe(SyntaxKind.ParenthesizedExpression); + // Without the option, the file is TypeScript's exact tree. + expect(raw[3]!.left!.kind).toBe(SyntaxKind.NonNullExpression); + expect(raw[2]!.left!.kind).toBe(SyntaxKind.ParenthesizedExpression); + expect(raw[5]!.operand!.kind).toBe(SyntaxKind.NonNullExpression); +}); + test("node lists report a trailing comma and modifier lists their flags", () => { const { file } = decoded.get("lists.ts")!; const lists: [SyntaxKind, boolean][] = []; diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index 8eaa87c72..f337bd623 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -60,6 +60,18 @@ export class AstModifierList extends AstNodeList * list. * Parent access is lazy, so resolving a checker handle near the bottom * of a deep tree never recursively constructs its ancestors. */ +export interface AstFileOptions { + /** Answer the reference an assignment, a `++`/`--` update or a for-in/of + * head writes without the parentheses and non-null assertions around it. + * TypeScript accepts both around a target (`(x) = 1`, `a[i]! += 1`) and + * both are erased at run time. With this option the writer's `left`, + * `operand` or `initializer` is the reference itself and the reference's + * `parent` is the writer, so every reader of a write target sees one + * shape. Positions stay the source's: the reference keeps its own span. + * Files read for other purposes keep TypeScript's exact tree. */ + writeTargets?: boolean; +} + export class AstFile { readonly wire: AstWireFile; readonly root: AstNode; @@ -73,10 +85,16 @@ export class AstFile { private lines: number[] | undefined; private childIndices: Uint32Array | undefined; + /** Whether nodes answer write targets without their wrappers (see + * AstFileOptions.writeTargets). */ + readonly writeTargets: boolean; + constructor( bytes: Uint8Array, private readonly materialized?: () => void, + options: AstFileOptions = {}, ) { + this.writeTargets = options.writeTargets === true; this.wire = new AstWireFile(bytes); this.nodes = new Array(this.wire.nodeCount); this.lists = new Array(this.wire.nodeCount); @@ -192,6 +210,73 @@ export class AstFile { /** Concrete native layout shared by every syntax kind. Kind-specific * properties are views over the wire; they never copy identity-bearing * nodes into structural records. */ +/* Write targets without their wrappers (AstFileOptions.writeTargets). */ + +function isTargetWrapper(node: AstNode): boolean { + return node.kind === AstKind.ParenthesizedExpression || node.kind === AstKind.NonNullExpression; +} + +function unwrapTarget(node: AstNode): AstNode { + let target = node; + while (isTargetWrapper(target)) { + const inner = target.childNode("expression"); + if (inner === undefined) break; + target = inner; + } + return target; +} + +/** Whether `field` of `node` is a write target: an assignment's left side, + * a `++`/`--` operand, or a for-in/of head that declares nothing. */ +function writesTarget(node: AstNode, field: "left" | "operand" | "initializer"): boolean { + switch (node.kind) { + case AstKind.BinaryExpression: { + if (field !== "left") return false; + const operator = node.childNode("operatorToken")?.kind; + return ( + operator !== undefined && + operator >= AstKind.FirstAssignment && + operator <= AstKind.LastAssignment + ); + } + case AstKind.PrefixUnaryExpression: + case AstKind.PostfixUnaryExpression: + return ( + field === "operand" && + (node.operator === AstKind.PlusPlusToken || node.operator === AstKind.MinusMinusToken) + ); + case AstKind.ForInStatement: + case AstKind.ForOfStatement: + return ( + field === "initializer" && + node.childNode("initializer")?.kind !== AstKind.VariableDeclarationList + ); + default: + return false; + } +} + +/** The parent of `node`, whose syntactic parent `wrapper` is a parenthesis + * or a non-null assertion: the writer when the wrappers around `node` are + * the whole target of an assignment, update or for-in/of head, and + * `wrapper` otherwise. */ +function writerOf(node: AstNode, wrapper: AstNode): AstNode { + let outer = wrapper; + for (;;) { + const up = outer.parent; + if (up === undefined) return wrapper; + if (isTargetWrapper(up)) { + outer = up; + continue; + } + for (const field of ["left", "operand", "initializer"] as const) { + if (writesTarget(up, field) && up.childNode(field) === outer && unwrapTarget(outer) === node) + return up; + } + return wrapper; + } +} + export class AstNode { readonly kind: SyntaxKind; readonly pos: number; @@ -221,7 +306,11 @@ export class AstNode { get parent(): AstNode | undefined { if (!this.parentResolved) { const index = this.file.wire.semanticParent(this.index); - this.parentCache = index === 0 || index === this.index ? undefined : this.file.node(index); + const parent = index === 0 || index === this.index ? undefined : this.file.node(index); + this.parentCache = + parent !== undefined && this.file.writeTargets && isTargetWrapper(parent) + ? writerOf(this, parent) + : parent; this.parentResolved = true; } return this.parentCache; @@ -483,6 +572,30 @@ export class AstNode { return token?.kind === SyntaxKind.QuestionToken ? token : undefined; } + // A write target keeps its parentheses and non-null assertions in the + // tree; with AstFileOptions.writeTargets these views return the target + // without them. + get initializer(): AstNode | undefined { + const initializer = this.childNode("initializer"); + return initializer !== undefined && this.file.writeTargets && writesTarget(this, "initializer") + ? unwrapTarget(initializer) + : initializer; + } + + get left(): AstNode | undefined { + const left = this.childNode("left"); + return left !== undefined && this.file.writeTargets && writesTarget(this, "left") + ? unwrapTarget(left) + : left; + } + + get operand(): AstNode | undefined { + const operand = this.childNode("operand"); + return operand !== undefined && this.file.writeTargets && writesTarget(this, "operand") + ? unwrapTarget(operand) + : operand; + } + // BEGIN GENERATED CHILD GETTERS // Generated from typescript@7.0.2; run scripts/generate-ts7-ast-schema.mjs. get argument(): AstNode | undefined { @@ -611,18 +724,12 @@ export class AstNode { get indexType(): AstNode | undefined { return this.childNode("indexType"); } - get initializer(): AstNode | undefined { - return this.childNode("initializer"); - } get jsdocPropertyTags(): AstNode | undefined { return this.childNode("jsdocPropertyTags"); } get label(): AstNode | undefined { return this.childNode("label"); } - get left(): AstNode | undefined { - return this.childNode("left"); - } get literal(): AstNode | undefined { return this.childNode("literal"); } @@ -662,9 +769,6 @@ export class AstNode { get openingFragment(): AstNode | undefined { return this.childNode("openingFragment"); } - get operand(): AstNode | undefined { - return this.childNode("operand"); - } get operatorToken(): AstNode | undefined { return this.childNode("operatorToken"); } diff --git a/packages/compiler/src/frontend/ts7/ast-wire.ts b/packages/compiler/src/frontend/ts7/ast-wire.ts index 4500ddee4..a2466a743 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire.ts @@ -72,6 +72,33 @@ export function parseAstNodeHandle(handle: string): AstNodeHandle { }; } +/** Node fields as word offsets into a node's NODE_LEN bytes. */ +const NODE_WORDS = NODE_LEN / 4; +const WORD_KIND = NODE_OFFSET_KIND / 4; +const WORD_POS = NODE_OFFSET_POS / 4; +const WORD_END = NODE_OFFSET_END / 4; +const WORD_NEXT = NODE_OFFSET_NEXT / 4; +const WORD_PARENT = NODE_OFFSET_PARENT / 4; +const WORD_DATA = NODE_OFFSET_DATA / 4; +const WORD_FLAGS = NODE_OFFSET_FLAGS / 4; +if ( + !Number.isInteger(NODE_WORDS) || + ![WORD_KIND, WORD_POS, WORD_END, WORD_NEXT, WORD_PARENT, WORD_DATA, WORD_FLAGS].every((word) => + Number.isInteger(word), + ) +) + throw new AstDecodeError("node fields are not word-aligned"); + +/** Kept out of line, so the index check that calls it stays small enough + * to inline into every node accessor. */ +function invalidNodeIndex(index: number): never { + throw new AstDecodeError(`invalid node index ${index}`); +} + +/** Whether this host stores a Uint32Array's words little-endian, the wire's + * byte order. */ +const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; + function hexWord(value: number): string { const digits = "0123456789abcdef"; let result = ""; @@ -93,12 +120,14 @@ export class AstWireFile { private readonly extended: number; private readonly structured: number; private readonly nodes: number; - private readonly view: DataView; - private readonly stringCache = new Map(); + /** The node table as 32-bit words: a view of the response when it is + * word-aligned on a little-endian host, otherwise a decoded copy. */ + private readonly words: Uint32Array; + /** Decoded strings by string word index / 2. */ + private readonly stringCache: (string | undefined)[]; constructor(private readonly bytes: Uint8Array) { astBounds(bytes, 0, HEADER_SIZE); - this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const metadata = astU32(bytes, HEADER_OFFSET_METADATA); if (metadata >>> 24 !== PROTOCOL_VERSION) throw new AstDecodeError(`unsupported protocol version ${metadata >>> 24}`); @@ -122,6 +151,15 @@ export class AstWireFile { } this.nodeCount = (bytes.length - this.nodes) / NODE_LEN; this.stringCount = (this.strings - this.stringOffsets) / 8; + const wordCount = this.nodeCount * NODE_WORDS; + const start = bytes.byteOffset + this.nodes; + if (LITTLE_ENDIAN && start % 4 === 0) { + this.words = new Uint32Array(bytes.buffer, start, wordCount); + } else { + this.words = new Uint32Array(wordCount); + for (let i = 0; i < wordCount; i++) this.words[i] = astU32(bytes, this.nodes + i * 4); + } + this.stringCache = new Array(this.stringCount).fill(undefined); if (this.nodeCount < 2) throw new AstDecodeError("missing root"); if (this.kind(1) === KIND_NODE_LIST || this.parent(1) > 1) throw new AstDecodeError("invalid root node"); @@ -142,40 +180,41 @@ export class AstWireFile { } } - private nodeOffset(index: number): number { - if (!Number.isInteger(index) || index < 0 || index >= this.nodeCount) - throw new AstDecodeError(`invalid node index ${index}`); - return this.nodes + index * NODE_LEN; + /** The first word of node `index`. `index >>> 0 === index` accepts exactly + * the integers in [0, 2^32), so with the bound it accepts the same indices + * as an integer and range check. */ + private nodeWord(index: number): number { + if (index >>> 0 !== index || index >= this.nodeCount) invalidNodeIndex(index); + return index * NODE_WORDS; } // The constructor validates the complete fixed-width node table, and - // nodeOffset validates its index. Read each word in one operation while - // preserving little-endian decoding even in an unaligned byte view. + // nodeWord validates its index. kind(index: number): number { - return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_KIND, true); + return this.words[this.nodeWord(index) + WORD_KIND]!; } pos(index: number): number { - return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_POS, true); + return this.words[this.nodeWord(index) + WORD_POS]! | 0; } end(index: number): number { - return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_END, true); + return this.words[this.nodeWord(index) + WORD_END]! | 0; } flags(index: number): number { - return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_FLAGS, true); + return this.words[this.nodeWord(index) + WORD_FLAGS]!; } data(index: number): number { - return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_DATA, true); + return this.words[this.nodeWord(index) + WORD_DATA]!; } next(index: number): number { - const next = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_NEXT, true); + const next = this.words[this.nodeWord(index) + WORD_NEXT]!; if (next !== 0 && (next <= index || next >= this.nodeCount)) throw new AstDecodeError("invalid sibling link"); return next; } parent(index: number): number { - const parent = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_PARENT, true); + const parent = this.words[this.nodeWord(index) + WORD_PARENT]!; if (parent >= this.nodeCount || (index <= 1 ? parent > index : parent >= index)) throw new AstDecodeError("invalid parent link"); return parent; @@ -188,14 +227,14 @@ export class AstWireFile { } string(index: number): string { - if (!Number.isInteger(index) || index < 0 || index % 2 !== 0 || index >= this.stringCount * 2) + if (index >>> 0 !== index || index % 2 !== 0 || index >= this.stringCount * 2) throw new AstDecodeError(`invalid string index ${index}`); - const cached = this.stringCache.get(index); + const cached = this.stringCache[index / 2]; if (cached !== undefined) return cached; const start = astU32(this.bytes, this.stringOffsets + index * 4); const end = astU32(this.bytes, this.stringOffsets + (index + 1) * 4); const text = decodeAstString(this.bytes, this.strings + start, end - start); - this.stringCache.set(index, text); + this.stringCache[index / 2] = text; return text; } @@ -250,7 +289,7 @@ export class AstWireFile { } firstChild(index: number): number { - this.nodeOffset(index); + this.nodeWord(index); return index + 1 < this.nodeCount && this.parent(index + 1) === index ? index + 1 : 0; } @@ -360,7 +399,7 @@ export class AstWireFile { const result: number[] = []; for (let i = 0; i < count; i++) { const index = reader.uint(); - this.nodeOffset(index); + this.nodeWord(index); if (index === 0 || this.kind(index) === KIND_NODE_LIST) throw new AstDecodeError("structured reference is not a node"); result.push(index); diff --git a/packages/compiler/src/frontend/ts7/session.ts b/packages/compiler/src/frontend/ts7/session.ts index c58588f6d..d3f2ae9fe 100644 --- a/packages/compiler/src/frontend/ts7/session.ts +++ b/packages/compiler/src/frontend/ts7/session.ts @@ -298,9 +298,13 @@ export class Ts7SessionProgram { if (source === undefined) { const bytes = this.session.binary("getSourceFile", JSON.stringify(this.query(file))); if (bytes.length !== 0) { - const ast = new AstFile(bytes, () => { - this.session.timing.materialized(); - }); + const ast = new AstFile( + bytes, + () => { + this.session.timing.materialized(); + }, + { writeTargets: true }, + ); this.session.timing.fetched(Math.max(0, ast.wire.nodeCount - 2)); source = this.session.cache.set(path, ast.sourceFile, this.snapshot.id, this.project.id); } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 28f23437d..c66e37901 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,5 +1,5 @@ import { optimizationField, type NativeOptimization } from "./backend/optimization.js"; -import { compilationTiming, type CompilationTiming } from "./timing.js"; +import { compilationTiming, type CompilationTiming, type TimingClock } from "./timing.js"; import { prepareExecutableModule } from "./executable/prepare.js"; import { prepareLibrary, @@ -45,9 +45,13 @@ export type { } from "./compile-types.js"; import { compilePackedLibrary } from "./backend/library-pack.js"; import { InternalCompilerError } from "./errors.js"; +import { frontendProcessExecArgv } from "./frontend-process-args.js"; +import { type ChildProcess, spawn } from "node:child_process"; import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { clearCcCaches, configuredTargetPlatform, @@ -67,6 +71,9 @@ import { emitLlvmModuleSource, LlvmUnsupportedError } from "./backend/llvm/emitt import { inlineRcOption } from "./backend/llvm/shapes.js"; import { emitNativeArtifact, + isLlvmFile, + llvmInputBytes, + type LlvmInput, NativeCodegenError, writeLlvmSource, } from "./backend/native-codegen.js"; @@ -114,16 +121,24 @@ import { import { type IrModule, type SrcLoc } from "./ir/ir.js"; import { moduleRuntimeFeatures } from "./ir/runtime-features.js"; import { serializeModuleParts } from "./ir/serialize.js"; -import { writeTextParts } from "./text-parts.js"; +import { readTextParts, writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; -import { provenanceSources } from "./frontend/provenance-registry.js"; +import { + type ProvenanceSources, + provenanceSources, + setProvenanceSources, +} from "./frontend/provenance-registry.js"; import { clearResolveCaches } from "./frontend/resolve.js"; import { loadFfiProfile, type FfiProfile } from "./ffi/ffi-manifest.js"; import { executableLinkFeatures } from "./backend/executable-features.js"; import { youngNurseryRequested } from "./backend/young-nursery.js"; -import { FrontendInputTracker, trackedReadFile } from "./frontend/input-tracker.js"; +import { + type FrontendInputSnapshot, + FrontendInputTracker, + trackedReadFile, +} from "./frontend/input-tracker.js"; import { libraryFrontendImplementationFingerprint, publishEarlyLibraryCache, @@ -135,9 +150,11 @@ import { type SemanticLibraryCacheHit, } from "./library/library-cache.js"; import { + hasEarlyExecutableNativeBuild, publishEarlyExecutableCache, publishEarlyExecutableRoute, readEarlyExecutableCache, + recordEarlyExecutableNativeBuild, type EarlyExecutableCacheOptions, type EarlyExecutableNativeFeatures, } from "./executable/executable-cache.js"; @@ -419,10 +436,16 @@ export async function compile( clearCompileSessionCaches(); const frontendInputs = new FrontendInputTracker(); const warnings: CompileWarningSink = { warnings: [], sourceTexts: null }; - const result = await frontendInputs.run(() => - compileTracked(entryPath, opts, frontendInputs, warnings), - ); - return withCompileWarnings(result, warnings); + // compileTracked starts it for a build with no earlier build of the same invocation. + const frontendProcess = preparesInChildProcess(opts) ? new FrontendProcess() : undefined; + try { + const result = await frontendInputs.run(() => + compileTracked(entryPath, opts, frontendInputs, warnings, frontendProcess), + ); + return withCompileWarnings(result, warnings); + } finally { + frontendProcess?.release(); + } } /** Divergence warnings collected while a compile lowers (none when a cache @@ -561,7 +584,7 @@ async function compileExecutableNative( ...(features.handleProps ? { handleProps: true } : {}), ...(features.dynForAwait ? { dynForAwait: true } : {}), ...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}), - ...(features.dynBufferNum ? { dynBufferNum: true } : {}), + ...(features.dynBuffer ? { dynBuffer: true } : {}), ...(features.builtinValues ? { builtinValues: true } : {}), events: features.events, emitter: features.emitter, @@ -613,7 +636,7 @@ interface NativeProgramObject { async function emitNativeProgramObject( entryPath: string, opts: CompileRequestOptions, - llvm: string | readonly string[], + llvm: LlvmInput, features: EarlyExecutableNativeFeatures, profile: NativeProfileOptions | undefined, ): Promise { @@ -625,10 +648,7 @@ async function emitNativeProgramObject( const linkPath = `${privateSiblingPath(artifactPath, "native-program-object")}.o`; // The validated program-object artifact is a single object by contract. const target = nativeCodegenTarget(); - const llvmBytes = (typeof llvm === "string" ? [llvm] : llvm).reduce( - (bytes, part) => bytes + Buffer.byteLength(part), - 0, - ); + const llvmBytes = llvmInputBytes(llvm); const partitions = opts.nativeProgramObject === true || target === null ? 1 : nativeProgramPartitions(llvmBytes); // Optimized programs import small runtime functions from the bitcode of @@ -718,7 +738,7 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD } interface PreparedExecutable { - llvmSource: string | readonly string[]; + llvmSource: LlvmInput; llvmPath: string; irPath: string | undefined; nativeFeatures: EarlyExecutableNativeFeatures; @@ -886,13 +906,247 @@ async function prepareExecutableInput( return { llvmSource, llvmPath, irPath, nativeFeatures, programSplit, sourceTexts }; } +/** + * The child process that prepares an executable build's input: frontend, lowering and LLVM emission + * (`runFrontendProcess`). Node's heap keeps the pages a build's frontend used for the life of the + * process, so running them in a process of their own leaves the native helper the memory they took. + */ +const frontendProcessEntry = fileURLToPath(new URL("./frontend-process.js", import.meta.url)); + +/** + * Whether `prepareExecutableInputInChild` may prepare this build: optimized executables, when the + * compiler runs from its built files. Development builds split the LLVM text in this process, and + * SCRIPTC_FRONTEND_PROCESS=0 keeps the frontend in-process. + * + * compileTracked starts the process only when no earlier build of the same invocation emitted its + * native program object (`hasEarlyExecutableNativeBuild`). A rebuild finds most native partitions + * in the helper's cache, so its memory peaks in the frontend, and the parent process would add to + * that peak. + */ +function preparesInChildProcess(opts: CompileRequestOptions): boolean { + return ( + process.env["SCRIPTC_FRONTEND_PROCESS"] !== "0" && + (opts.outputKind ?? "exe") === "exe" && + (opts.optimization ?? "release") !== "dev" && + existsSync(frontendProcessEntry) + ); +} + +/** + * The frontend process (frontend-process.ts) of one compile. `start` launches it; it then waits on + * its stdin for the paths of a request and its result (`run`), and exits once its stdin closes + * without them (`release`). + */ +class FrontendProcess { + private child: ChildProcess | undefined; + private exited: Promise | undefined; + private sent = false; + + /** Whether `start` launched the process. */ + get started(): boolean { + return this.child !== undefined; + } + + start(): void { + if (this.child !== undefined) return; + const execArgv = frontendProcessExecArgv(process.execArgv); + const child = spawn(process.execPath, [...execArgv, frontendProcessEntry], { + stdio: ["pipe", "inherit", "inherit"], + }); + this.exited = new Promise((resolveExit) => { + child.on("error", () => resolveExit(null)); + child.on("exit", (code) => resolveExit(code)); + }); + // A failed start surfaces as a failed run; the build then prepares its input in-process. + child.stdin!.on("error", () => undefined); + this.child = child; + } + + /** Sends the request at `requestPath`; resolves with the process's exit code. */ + run(requestPath: string, resultPath: string): Promise { + if (this.child === undefined || this.exited === undefined || this.sent) + return Promise.resolve(null); + this.sent = true; + this.child.stdin!.end(`${requestPath}\n${resultPath}\n`); + return this.exited; + } + + /** Lets the process exit if no request was sent. */ + release(): void { + if (this.child === undefined || this.sent) return; + this.sent = true; + this.child.stdin!.end(); + // The build is done with it: this process need not wait for it to exit. + this.child.unref(); + (this.child.stdin as { unref?: () => void }).unref?.(); + } +} + +interface FrontendProcessRequest { + entryPath: string; + opts: CompileRequestOptions; + buildPlatform: string; + /** The compile's SCRIPTC_TIMING clock, which the frontend process's phases continue. */ + timing: TimingClock; + /** The compile's --provenance-sources mappings, which the frontend process installs. */ + provenance: ProvenanceSources | null; +} + +/** What the frontend process sends back. A thrown error comes back as `failed`. */ +type FrontendProcessResult = + | { + kind: "prepared"; + llvmPath: string; + /** A private copy of the LLVM text, which the native helper reads (`nativeInputPath`). */ + nativeInputPath: string; + llvmBytes: number; + irPath: string | undefined; + nativeFeatures: EarlyExecutableNativeFeatures; + sourceTexts: [string, string][]; + warnings: ScrDiagnostic[]; + frontend: FrontendInputSnapshot; + } + | { + kind: "diagnostics"; + diagnostics: ScrDiagnostic[]; + sourceTexts: [string, string][]; + warnings: ScrDiagnostic[]; + frontend: FrontendInputSnapshot; + } + | { kind: "failed" }; + +/** + * `prepareExecutableInput` in the frontend process, with the LLVM text passed on as its file. The + * process's frontend inputs join `frontendInputs` and its warnings `warningSink`. If it fails, the + * input is prepared here, so the build reports the same errors. + */ +async function prepareExecutableInputInChild( + frontendProcess: FrontendProcess, + entryPath: string, + opts: CompileRequestOptions, + ffi: FfiProfile | null, + buildPlatform: string, + timing: CompilationTiming, + timingClock: TimingClock, + frontendInputs: FrontendInputTracker, + warningSink?: CompileWarningSink, +): Promise { + await mkdir(opts.outDir, { recursive: true }); + const requestPath = privateSiblingPath(join(opts.outDir, "frontend-request.json"), "frontend"); + const resultPath = privateSiblingPath(join(opts.outDir, "frontend-result.json"), "frontend"); + let result: FrontendProcessResult = { kind: "failed" }; + try { + const request: FrontendProcessRequest = { + entryPath, + opts, + buildPlatform, + timing: timingClock, + provenance: provenanceSources(), + }; + await writeFile(requestPath, JSON.stringify(request), { mode: 0o600 }); + const exitCode = await frontendProcess.run(requestPath, resultPath); + if (exitCode === 0) + result = JSON.parse(await readFile(resultPath, "utf8")) as FrontendProcessResult; + } catch { + result = { kind: "failed" }; + } finally { + await rm(requestPath, { force: true }); + await rm(resultPath, { force: true }); + } + if (result.kind === "failed") + return prepareExecutableInput(entryPath, opts, ffi, buildPlatform, timing, warningSink); + timing("frontend-process"); + for (const probe of result.frontend.probes) frontendInputs.record(probe); + if (!result.frontend.stable) frontendInputs.markUnstable(); + const sourceTexts = new Map(result.sourceTexts); + if (warningSink !== undefined && result.warnings.length > 0) { + warningSink.warnings = result.warnings; + warningSink.sourceTexts = sourceTexts; + } + if (result.kind === "diagnostics") + return { ok: false, diagnostics: result.diagnostics, sourceTexts }; + return { + llvmSource: { file: result.nativeInputPath, bytes: result.llvmBytes }, + llvmPath: result.llvmPath, + irPath: result.irPath, + nativeFeatures: result.nativeFeatures, + programSplit: null, + sourceTexts, + }; +} + +/** + * The frontend process (frontend-process.ts): prepares the executable input of the request at + * `requestPath` as `compileTracked` would in-process, and writes what it prepared, without the LLVM + * text, to `resultPath`. + */ +export async function runFrontendProcess(requestPath: string, resultPath: string): Promise { + const request = JSON.parse(await readFile(requestPath, "utf8")) as FrontendProcessRequest; + clearCompileSessionCaches(); + setProvenanceSources(request.provenance); + const frontendInputs = new FrontendInputTracker(); + const warnings: CompileWarningSink = { warnings: [], sourceTexts: null }; + let result: FrontendProcessResult = { kind: "failed" }; + try { + let ffi: FfiProfile | null = null; + if (request.opts.ffiProfilePath !== undefined) { + const loaded = loadFfiProfile(resolve(request.opts.ffiProfilePath)); + if (!loaded.ok) throw new InternalCompilerError("the FFI profile could not be reloaded"); + ffi = loaded.profile; + } + const prepared = await frontendInputs.run(() => + prepareExecutableInput( + request.entryPath, + request.opts, + ffi, + request.buildPlatform, + compilationTiming(request.timing), + warnings, + ), + ); + const frontend = frontendInputs.snapshot(); + if (!("ok" in prepared)) { + // The public .ll file may be rewritten by a concurrent build of another entry with the + // same output stem before the native helper reads it (see emitNativeArtifact). + if (isLlvmFile(prepared.llvmSource)) + throw new InternalCompilerError("in-process preparation returned LLVM text as a file"); + const nativeInputPath = privateSiblingPath(prepared.llvmPath, "frontend-llvm"); + await writeLlvmSource(nativeInputPath, prepared.llvmSource, 0o600); + result = { + kind: "prepared", + llvmPath: prepared.llvmPath, + nativeInputPath, + llvmBytes: llvmInputBytes(prepared.llvmSource), + irPath: prepared.irPath, + nativeFeatures: prepared.nativeFeatures, + sourceTexts: [...prepared.sourceTexts], + warnings: warnings.warnings, + frontend, + }; + } else if (!prepared.ok) { + result = { + kind: "diagnostics", + diagnostics: prepared.diagnostics, + sourceTexts: [...prepared.sourceTexts], + warnings: warnings.warnings, + frontend, + }; + } + } catch { + result = { kind: "failed" }; + } + await writeFile(resultPath, JSON.stringify(result), { mode: 0o600 }); +} + async function compileTracked( entryPath: string, opts: CompileRequestOptions, frontendInputs: FrontendInputTracker, warningSink?: CompileWarningSink, + frontendProcess?: FrontendProcess, ): Promise { const timing = compilationTiming(); + const timingStart = Date.now(); entryPath = resolve(entryPath); const outputKind = opts.outputKind ?? "exe"; if ( @@ -1121,18 +1375,12 @@ async function compileTracked( if (outputKind === "exe") { const helperObjectRoute = opts.nativeProgramObject === true || usesPrecompiledRuntimePack(opts, "llvm"); - // Package content and native driver discovery are independent inputs to - // the same cache key. Start both before waiting so edits do not pay their - // filesystem and subprocess latency serially. - const [implementation, nativeEnvironment] = await Promise.all([ - compilerImplementationIdentity(), - helperObjectRoute - ? executableLinkerEnvironmentFingerprint(process.env, nativeCodegenTarget()?.defaultLinker) - : executableNativeEnvironmentFingerprint(), - ]); // Calls into Node record the libnode pack (node-islands/libnode.ts). const libnodeKey = opts.libnode ?? process.env["SCRIPTC_LIBNODE"]; - earlyCacheOptions = { + const routeOptions: Omit< + EarlyExecutableCacheOptions, + "implementation" | "implementationDependencies" | "nativeEnvironment" + > = { entryPath, outDir: opts.outDir, outPath: opts.outPath, @@ -1164,8 +1412,27 @@ async function compileTracked( ? resolvePlatformLinker(process.env, nativeCodegenTarget()?.defaultLinker) : (process.env["SCRIPTC_CC"] ?? "clang"), ], - nativeEnvironment, nodeVersion: process.version, + }; + // The frontend process prepares the first build of an invocation (preparesInChildProcess). + // Started here, it loads the compiler while this process computes the rest of the cache key. + if ( + frontendProcess !== undefined && + !(await hasEarlyExecutableNativeBuild(cacheRoot, routeOptions)) + ) + frontendProcess.start(); + // Package content and native driver discovery are independent inputs to + // the same cache key. Start both before waiting so edits do not pay their + // filesystem and subprocess latency serially. + const [implementation, nativeEnvironment] = await Promise.all([ + compilerImplementationIdentity(), + helperObjectRoute + ? executableLinkerEnvironmentFingerprint(process.env, nativeCodegenTarget()?.defaultLinker) + : executableNativeEnvironmentFingerprint(), + ]); + earlyCacheOptions = { + ...routeOptions, + nativeEnvironment, implementation: implementation.digest, implementationDependencies: implementation.dependencies, }; @@ -1216,7 +1483,9 @@ async function compileTracked( nativeProgramObject = await emitNativeProgramObject( entryPath, opts, - await readFile(earlyHit.llvmPath, "utf8"), + // A large program's LLVM exceeds the engine's string limit as one + // string; the emitter takes it in parts, as on a cold build. + await readTextParts(earlyHit.llvmPath), earlyHit.native, profile, ); @@ -1299,14 +1568,20 @@ async function compileTracked( }; } timing("executable-cache-miss"); - const prepared = await prepareExecutableInput( - entryPath, - opts, - ffi, - buildPlatform, - timing, - warningSink, - ); + const prepared = + frontendProcess?.started === true + ? await prepareExecutableInputInChild( + frontendProcess, + entryPath, + opts, + ffi, + buildPlatform, + timing, + { start: timingStart, previous: Date.now() }, + frontendInputs, + warningSink, + ) + : await prepareExecutableInput(entryPath, opts, ffi, buildPlatform, timing, warningSink); if ("ok" in prepared) return prepared; const { llvmSource, llvmPath, irPath, nativeFeatures, programSplit, sourceTexts } = prepared; const backend = "llvm" as const; @@ -1335,6 +1610,9 @@ async function compileTracked( profile, ); timing("native-object"); + await recordEarlyExecutableNativeBuild(cacheRoot, executableCacheOptions).catch( + () => undefined, + ); } catch (err) { if (!(err instanceof NativeCodegenError)) throw err; return { @@ -1395,6 +1673,8 @@ async function compileTracked( nativeProgramObject.partitionPaths, ); } + // The frontend process's private copy of the LLVM text. + if (isLlvmFile(llvmSource)) await rm(llvmSource.file, { force: true }); } if (!publishedExecutable) { await publishEarlyExecutableCache(cacheRoot, executableCacheOptions, { @@ -1445,6 +1725,7 @@ function libraryNativeFeatures(mod: IrModule, backend: "llvm"): EarlyLibraryNati copying: features.copying, textDecoderLegacy: features.legacyTextDecoder, dynInvoke: features.dynInvoke, + ...(features.dynBuffer ? { dynBuffer: true } : {}), ...(features.dynTier ? { dynTier: true } : {}), ...(mod.lib?.identity !== undefined ? { buildId: mod.lib.identity.buildId } : {}), }; @@ -1539,6 +1820,7 @@ async function compileLibraryNative( copying: features.copying, textDecoderLegacy: features.textDecoderLegacy, dynInvoke: features.dynInvoke, + ...(features.dynBuffer === true ? { dynBuffer: true } : {}), ...(features.dynTier === true ? { dynTier: true } : {}), }; if (packTarget !== null) await compilePackedLibrary(archiveOptions, packTarget); diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 244129097..2741b2f37 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -240,12 +240,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // 'error' has no listener, Node's unhandled-'error' contract. "emitter.emit", "emitter.emitFlex", + "emitter.emitAny", + "emitter.emitMixed", + "emitter.listenersDyn", "emitter.emitError", "emitter.on", "emitter.off", "emitter.checkListener", "emitter.onDyn", "emitter.onFlex", + "emitter.onAny", "emitter.offDyn", "emitter.onData", "emitter.onDataDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 973d79514..016824dfc 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3148,6 +3148,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * scandir failure becomes its rejection. */ "fsp.readdirTypes": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, "fsp.rm": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, + /** `fs.promises.rm(path, options)`: fs.rmOptsSync and fs.rmRetrySync + * behind a settled promise; their errors become its rejection. */ + "fsp.rmOpts": { argTypes: [STRING, BOOL, BOOL], result: { kind: "promise", inner: VOID } }, + "fsp.rmRetry": { + argTypes: [STRING, BOOL, BOOL, F64, F64], + result: { kind: "promise", inner: VOID }, + }, "fsp.stat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } }, "fsp.realpath": { argTypes: [STRING], result: { kind: "promise", inner: STRING } }, "fsp.lstat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } }, @@ -3765,16 +3772,31 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "emitter.checkListener": { argTypes: [DYN], result: VOID }, "emitter.onDyn": { argTypes: [null, STRING, DYN, null, BOOL, BOOL], result: VOID }, "emitter.onFlex": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID }, + /** on/once/prepend* and emit through an untyped value (the class + * dispatch of compiled emitters): onFlex/emitFlex plus runtime typed + * adapters under 'error' and the meta events, and Node's unhandled-'error' + * contract. emitAny is VARIADIC like emitFlex. */ + "emitter.onAny": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID }, "emitter.offDyn": { argTypes: [null, STRING, DYN], result: VOID }, "emitter.removeAll": { argTypes: [null, STRING, BOOL], result: VOID }, "emitter.emit": { argTypes: [null, STRING], result: BOOL }, "emitter.emitFlex": { argTypes: [null, STRING], result: BOOL }, + "emitter.emitAny": { argTypes: [null, STRING], result: BOOL }, + /** An untyped emit of an event with a fixed tuple: (recv, name, k, then + * k checked-dynamic arguments as given, then the event's converted + * tuple) — VARIADIC like emitter.emit. hasTyped answers whether a + * listener of the name needs the converted tuple. */ + "emitter.emitMixed": { argTypes: [null, STRING, F64], result: BOOL }, + "emitter.hasTyped": { argTypes: [null, STRING], result: BOOL }, "emitter.emitError": { argTypes: [null, STRING, null], result: BOOL }, "emitter.count": { argTypes: [null, STRING], result: F64 }, "emitter.countFn": { argTypes: [null, STRING, null], result: F64 }, "emitter.countDyn": { argTypes: [null, STRING, DYN], result: F64 }, "emitter.names": { argTypes: [null], result: VOID }, "emitter.listeners": { argTypes: [null, STRING], result: VOID }, + /** listeners()/rawListeners() through an untyped value: a checked- + * dynamic array (throws when a listener has typed parameters). */ + "emitter.listenersDyn": { argTypes: [null, STRING], result: DYN }, /** Stream-'data' registration twins of emitter.on/onDyn (same args): * chosen at registration sites whose receiver is stream-rooted, so the * backend emits DATA thunks — the runtime's 'data' emission carries @@ -4631,8 +4653,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * return value has no lowering (statement position only, frontend- * fenced). rmOptsSync is rmSync with (recursive, force) bools: force * swallows ENOENT, recursive removes trees post-order, a directory - * without recursive throws the EISDIR-worded error (divergence 13's - * wording note). mkdtempSync appends the six X's and returns the + * without recursive throws Node's ERR_FS_EISDIR SystemError. mkdtempSync appends the six X's and returns the * created path (+1). accessSync takes the F_OK/R_OK/W_OK/X_OK bits as * one f64 (the frontend bakes fs.constants.* as literals and completes * an omitted mode to 0). readFdSync/readFdSyncBytes are the @@ -4643,7 +4664,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** rmOptsSync's maxRetries/retryDelay form (path, recursive, force, * maxRetries, retryDelay): Node's linear-backoff retry on * EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM — the tmpdir-harness shape - * rmSync(p, { maxRetries: 3, recursive: true, force: true }). */ + * rmSync(p, { maxRetries: 3, recursive: true, force: true }). Throws + * ERR_OUT_OF_RANGE for a retryDelay outside int32 >= 0 or a maxRetries + * outside uint32, before checking the path. */ "fs.rmRetrySync": { argTypes: [STRING, BOOL, BOOL, F64, F64], result: VOID }, "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, diff --git a/packages/compiler/src/ir/final-fields.ts b/packages/compiler/src/ir/final-fields.ts index 21572c910..b0340cbaa 100644 --- a/packages/compiler/src/ir/final-fields.ts +++ b/packages/compiler/src/ir/final-fields.ts @@ -1,5 +1,5 @@ import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js"; -import { dynamicFieldStores } from "./int32-slots.js"; +import { dynamicFieldStores, type DynamicFieldStores } from "./int32-slots.js"; import { everyExprChild, everyStmtChild } from "./traverse.js"; /** Whole-program final reference fields. @@ -78,11 +78,15 @@ export function constructorClass(fnName: string): string | null { /** `scalars` extends the analysis from reference-counted fields to fields of * every type, for consumers that read a final field's value once instead of * borrowing it (field-values.ts). */ -export function analyzeFinalFields(mod: IrModule, scalars = false): FinalFields { +export function analyzeFinalFields( + mod: IrModule, + scalars = false, + stores?: DynamicFieldStores, +): FinalFields { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const finals = new FinalFields(classes); if (mod.lib) return finals; - const dynamic = dynamicFieldStores(mod); + const dynamic = stores ?? dynamicFieldStores(mod); if (dynamic.all) return finals; const genericFamilies = new Set(); diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index f26a89f22..8b4fffcef 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -214,6 +214,8 @@ const MODULE_FIELDS: Record>, Role> = { "contracts.programLifetime[]": "other", "wasmModules[].sha256": "other", "wasmModules[].bytes": "other", + "wasmUntranslated[].sha256": "other", + "wasmUntranslated[].reason": "other", "threadSlotKeys[]": "other", "nodeIsland.libnode": "other", "nodeIsland.nodeVersion": "other", diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index a08693013..781fa86bd 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -1,4 +1,4 @@ -import type { IrClassDef, IrExpr, IrFunction, IrModule, IrStmt, IrType } from "./ir.js"; +import type { IrClassDef, IrExpr, IrFunction, IrGlobal, IrModule, IrStmt, IrType } from "./ir.js"; import { analyzeIntegerRanges, INT32_RANGE, @@ -458,11 +458,18 @@ function walkFunction( * whose key is not a literal), otherwise the * literal keys of `dyn.keySet`/`dyn.keyDelete`. A view commits a store back * into the instance's native field slots, which the IR does not show. */ +export interface DynamicFieldStores { + all: boolean; + names: Set; +} + +/** Computes DynamicFieldStores with one walk of every function. Analyses of + * the same module accept the result so the emitter walks the module once. */ export function dynamicFieldStores( mod: IrModule, records = new Map((mod.records ?? []).map((r) => [r.id, r])), unions = new Map((mod.unions ?? []).map((u) => [u.id, u])), -): { all: boolean; names: Set } { +): DynamicFieldStores { const dynTypes = new Map(); const dynNamed = new Map(); const dynish = (type: IrType): boolean => { @@ -522,7 +529,11 @@ export function dynamicFieldStores( * can write: the class reaches checked-dynamic code (directly or through * another exposed type), and some dynamic store in the program can name the * field. */ -export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set { +export function dynamicallyStoredFamilies( + mod: IrModule, + slots: Int32Slots, + stores?: DynamicFieldStores, +): Set { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const children = new Map(); for (const cls of classes.values()) @@ -588,7 +599,8 @@ export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set // writes every field from the view, which holds the exact snapshot value // of any property no store named. So reaching dynamic code disqualifies // exactly the field names some dynamic store in the program can name. - const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions); + const { all: dynWritesAll, names: dynWritten } = + stores ?? dynamicFieldStores(mod, records, unions); for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { @@ -655,20 +667,11 @@ export function functionCallFacts( const runtimePrefixes: string[] = []; for (const cls of classes.values()) if (runtimeRooted(cls.name)) runtimePrefixes.push(`%${cls.name}.`); - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - const referenced = exprFunctionReference(e); - if (referenced !== undefined) escaped.add(referenced); - if (e.kind !== "virtualCall") return; - for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`); - for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined) - virtualTargets.add(`%${c.name}.${e.method}`); - }, - }); // A class value can reach `new` through its construct thunk; one that - // only feeds an instanceof interval check cannot. + // only feeds an instanceof interval check cannot. Class references are + // checked once every function has contributed its tested-only ones. const testedOnly = new Set(); + const classRefs: (IrExpr & { kind: "classRef" })[] = []; for (const fn of mod.functions) { // Direct operands, and unboxed locals bound once to a class reference // and read only as instanceof operands (the lowering's temporary). @@ -681,11 +684,18 @@ export function functionCallFacts( }; walkFunction(fn, { expr: (e) => { - if (e.kind === "instanceOfValue") { + const referenced = exprFunctionReference(e); + if (referenced !== undefined) escaped.add(referenced); + if (e.kind === "virtualCall") { + for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`); + for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined) + virtualTargets.add(`%${c.name}.${e.method}`); + } else if (e.kind === "instanceOfValue") { if (e.classValue.kind === "classRef") testedOnly.add(e.classValue); else if (e.classValue.kind === "varRef") count(tests, e.classValue.localId); } else if (e.kind === "varRef") count(reads, e.localId); else if (e.kind === "assignExpr" || e.kind === "incDec") count(writes, e.localId); + else if (e.kind === "classRef") classRefs.push(e); }, stmt: (s) => { if (s.kind === "varDecl" && s.init?.kind === "classRef") bound.set(s.localId, s.init); @@ -698,18 +708,19 @@ export function functionCallFacts( if (!boxed.has(id) && writes.get(id) === 1 && reads.get(id) === tests.get(id)) testedOnly.add(ref); } - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (e.kind === "classRef" && !testedOnly.has(e)) escaped.add(`%${e.className}.constructor`); - }, - }); + for (const e of classRefs) if (!testedOnly.has(e)) escaped.add(`%${e.className}.constructor`); const runtimeVirtual = (name: string): boolean => runtimePrefixes.some((prefix) => name.startsWith(prefix)); return { valueEscaped: escaped, virtualTargets, runtimeVirtual }; } -export function analyzeInt32Slots(mod: IrModule): Int32Slots { +/** Facts about the same module that a caller has already computed. */ +export interface Int32SlotInputs { + dynamicStores?: DynamicFieldStores; + initializerBindings?: ReadonlyMap; +} + +export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const slots = new Int32Slots(classes); if (mod.lib) return slots; @@ -780,7 +791,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { } /* ── dynamic, reflective and runtime exposure ── */ - for (const family of dynamicallyStoredFamilies(mod, slots)) { + for (const family of dynamicallyStoredFamilies(mod, slots, inputs.dynamicStores)) { if (slots.isPinnedField(family)) continue; dropFamily(family, "dynamic stores can name the field"); } @@ -799,56 +810,16 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { const dropAllParams = (fn: IrFunction, why: string): void => { for (const param of fn.params) dropParam(fn.name, param.localId, why); }; - // Only functions some IR call reaches directly get parameter facts: one - // the backend invokes by convention has no call site to prove against. + // One walk of every function collects what the steps below need: the + // functions IR calls reach directly, calls whose arity differs from their + // callee's, the captured bindings' boxes, and which functions read which + // slot facts. Facts that depend on earlier steps are applied in walk order + // where those steps complete. const called = slots.called; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (e.kind === "call") called.add(e.callee); - else if (e.kind === "new") called.add(`%${e.className}.constructor`); - }, - }); - for (const fn of mod.functions) { - if (fn.returnType.kind === "f64" && !fn.async && !fn.generator) { - slots.addReturn(fn.name); - slots.assumeNonNegative(`ret:${fn.name}`); - } - if ( - fn.async || - fn.generator || - !called.has(fn.name) || - escaped.has(fn.name) || - virtual(fn.name) - ) - continue; - for (const param of fn.params) - if (param.type.kind === "f64" && !slots.isPinned(paramKey(fn.name, param.localId))) { - slots.addParam(fn.name, param.localId); - slots.assumeNonNegative(paramKey(fn.name, param.localId)); - } - } + const arityMismatches: IrFunction[] = []; const arityChecked = (fn: IrFunction | undefined, args: number, offset: number): void => { - if (fn && fn.params.length !== args + offset) dropAllParams(fn, "call arity differs"); + if (fn && fn.params.length !== args + offset) arityMismatches.push(fn); }; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (e.kind === "call") arityChecked(functions.get(e.callee), e.args.length, 0); - else if (e.kind === "new") - arityChecked(functions.get(`%${e.className}.constructor`), e.args.length, 1); - }, - }); - - // Candidates the fixpoint may drop; rangedParameters retries some. - const candidates = new Map(); - const retry = new Set(); - for (const fn of mod.functions) { - const ids = fn.params.filter((p) => slots.hasParam(fn.name, p.localId)).map((p) => p.localId); - if (ids.length > 0) candidates.set(fn.name, ids); - } - - /* ── captured bindings ── */ // A captured local lives in one box shared by its declaring function and // every closure that captures it (closure.captures[i] is the lifted // function's captures[i]). The box holds an int32 when every write to it @@ -864,6 +835,16 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { const boxKey = (fnName: string, localId: string): string => `${fnName}\0${localId}`; const boxMembers = new Set(); const unstableBoxes = new Set(); + // Readers: which functions depend on which slot facts. Reads of a boxed + // local resolve to its box once the boxes are known. + const readers = new Map>(); + const reads = (slot: string, fnName: string): void => { + const set = readers.get(slot); + if (set) set.add(fnName); + else readers.set(slot, new Set([fnName])); + }; + const boxedReads: [IrFunction, Set][] = []; + const incrementedFamilies: string[] = []; for (const fn of mod.functions) { for (const local of fn.locals) if (local.boxed) { @@ -873,9 +854,27 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { } for (const capture of fn.classCaptures ?? []) unstableBoxes.add(boxKey(fn.name, capture.localId)); + const read = new Set(); walkFunction(fn, { expr: (e) => { - if (e.kind === "closure") { + if (e.kind === "call") { + called.add(e.callee); + arityChecked(functions.get(e.callee), e.args.length, 0); + reads(`ret:${e.callee}`, fn.name); + } else if (e.kind === "new") { + const constructor = `%${e.className}.constructor`; + called.add(constructor); + arityChecked(functions.get(constructor), e.args.length, 1); + // Constructors return nothing: this only records their callers. + reads(`ret:${constructor}`, fn.name); + } else if (e.kind === "varRef") read.add(e.localId); + else if (e.kind === "fieldGet") { + const family = slots.family(e.className, e.field); + if (family) reads(`field:${family}`, fn.name); + } else if (e.kind === "fieldIncDec") { + const family = slots.family(e.className, e.field); + if (family) incrementedFamilies.push(family); + } else if (e.kind === "closure") { const lifted = functions.get(e.fnName); e.captures.forEach((id, i) => { const inner = lifted?.captures?.[i]; @@ -897,7 +896,41 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { unstableBoxes.add(boxKey(fn.name, s.catchLocalId)); }, }); + if (read.size > 0) boxedReads.push([fn, read]); } + + // Only functions some IR call reaches directly get parameter facts: one + // the backend invokes by convention has no call site to prove against. + for (const fn of mod.functions) { + if (fn.returnType.kind === "f64" && !fn.async && !fn.generator) { + slots.addReturn(fn.name); + slots.assumeNonNegative(`ret:${fn.name}`); + } + if ( + fn.async || + fn.generator || + !called.has(fn.name) || + escaped.has(fn.name) || + virtual(fn.name) + ) + continue; + for (const param of fn.params) + if (param.type.kind === "f64" && !slots.isPinned(paramKey(fn.name, param.localId))) { + slots.addParam(fn.name, param.localId); + slots.assumeNonNegative(paramKey(fn.name, param.localId)); + } + } + for (const fn of arityMismatches) dropAllParams(fn, "call arity differs"); + + // Candidates the fixpoint may drop; rangedParameters retries some. + const candidates = new Map(); + const retry = new Set(); + for (const fn of mod.functions) { + const ids = fn.params.filter((p) => slots.hasParam(fn.name, p.localId)).map((p) => p.localId); + if (ids.length > 0) candidates.set(fn.name, ids); + } + + /* ── captured bindings ── */ const groups = new Map(); for (const key of boxMembers) { const root = boxRoot(key); @@ -960,34 +993,15 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { return slots.dropBox(group); }; - /* ── readers: which functions depend on which slot facts ── */ - const readers = new Map>(); - const reads = (slot: string, fnName: string): void => { - const set = readers.get(slot); - if (set) set.add(fnName); - else readers.set(slot, new Set([fnName])); - }; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (e.kind === "fieldGet") { - const family = slots.family(e.className, e.field); - if (family) reads(`field:${family}`, fn.name); - } else if (e.kind === "call") reads(`ret:${e.callee}`, fn.name); - // Constructors return nothing: this only records their callers. - else if (e.kind === "new") reads(`ret:%${e.className}.constructor`, fn.name); - else if (e.kind === "varRef") { - const group = slots.boxGroup(fn.name, e.localId); - if (group !== null) reads(`box:${group}`, fn.name); - } else if (e.kind === "fieldIncDec") { - const family = slots.family(e.className, e.field); - if (family) dropFamily(family, "++/-- on the field"); - } - }, - }); + for (const [fn, read] of boxedReads) + for (const localId of read) { + const group = slots.boxGroup(fn.name, localId); + if (group !== null) reads(`box:${group}`, fn.name); + } + for (const family of incrementedFamilies) dropFamily(family, "++/-- on the field"); /* ── the optimistic fixpoint ── */ - const bindings = findInitializerBindings(mod); + const bindings = inputs.initializerBindings ?? findInitializerBindings(mod); const numeric = (fn: IrFunction): IrFunction => withInitializerBindings(fn, bindings.get(fn.name) ?? []); const queue: string[] = mod.functions.map((fn) => fn.name); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 1b6001a3f..731a5cabd 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1058,6 +1058,10 @@ export interface IrModule { * API (possibly empty). The backend appends each module's translation * and emits the registry the runtime looks modules up in. */ wasmModules?: IrWasmModule[]; + /** The modules the build saw but does not translate, with the reason, + * present with wasmModules. The backend emits them in a second table, so + * loading one rejects with a CompileError that names the reason. */ + wasmUntranslated?: IrWasmUntranslated[]; /** The program calls WebAssembly.Module.customSections, so translated * modules keep their custom sections (otherwise they carry none, and * debug sections cost nothing). */ @@ -1073,6 +1077,16 @@ export interface IrWasmModule { bytes: string; } +/** A WebAssembly module whose bytes the build saw but does not translate. */ +export interface IrWasmUntranslated { + /** Hex SHA-256 of the bytes. */ + sha256: string; + /** The byte length. */ + size: number; + /** Why the module is not translated, as a clause ("it uses simd, ..."). */ + reason: string; +} + /** Verified contract facts, keyed by IR names. */ export interface IrContracts { /** Int32 class field families: `${topmost declaring class}\0${field}`. */ @@ -3550,11 +3564,13 @@ export type IrExpr = * excluding callable and primitive references; * "instanceof" requires `className`: hierarchy payloads use the vtable * interval, while standalone classes use their exact retain adapter. - * Other payloads answer false. `negated` flips the result (the `!==` spelling). */ + * Other payloads answer false. "dynamic" is a value thrown as unknown + * (the payload caughtToDyn returns by reference). `negated` flips the + * result (the `!==` spelling). */ | { kind: "caughtTest"; value: IrExpr; - test: "string" | "number" | "boolean" | "object" | "instanceof"; + test: "string" | "number" | "boolean" | "object" | "instanceof" | "dynamic"; className?: string; negated?: boolean; type: IrType; diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 3660a2300..3af064502 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -10,9 +10,7 @@ import { import { everyModuleNode } from "./traverse.js"; import { JsIcSites, hasJsIcSite } from "./js-ic.js"; -/** The fixed-width Buffer read/write method names (both "UInt" and "Uint" - * spellings) a checked-dynamic method call routes to scr_bytes_dyn.c. */ -export const DYN_BUFFER_NUM_METHODS: ReadonlySet = new Set( +const DYN_BUFFER_NUM_METHODS: ReadonlySet = new Set( ["read", "write"].flatMap((rw) => [ ...["UInt", "Uint"].flatMap((u) => ["8", "16LE", "16BE", "32LE", "32BE"].map((w) => `${rw}${u}${w}`), @@ -31,6 +29,45 @@ export const DYN_BUFFER_NUM_METHODS: ReadonlySet = new Set( ]), ); +/** Buffer.prototype members scr_bytes_dyn.c refuses on Buffer receivers. */ +const DYN_BUFFER_REFUSED_METHODS: readonly string[] = [ + ...["read", "write"].flatMap((rw) => [ + ...["UIntLE", "UIntBE", "UintLE", "UintBE", "IntLE", "IntBE"].map((n) => `${rw}${n}`), + ...["BigUInt64", "BigUint64", "BigInt64"].flatMap((n) => [`${rw}${n}LE`, `${rw}${n}BE`]), + ]), + ...["ascii", "base64", "base64url", "latin1", "hex", "ucs2", "utf8"].flatMap((enc) => [ + `${enc}Slice`, + `${enc}Write`, + ]), + "inspect", +]; + +/** The scr_bytes_dyn.c entry, one per method family, that a checked-dynamic + * call of a Buffer.prototype method name routes to: the fixed-width reads and + * writes (both "UInt" and "Uint" spellings), write, fill, toString, the + * searches, and the other Buffer-only members. Each runs the method on a + * Buffer receiver and gives any other receiver the ordinary dispatch. */ +export const DYN_BUFFER_METHODS: ReadonlyMap = new Map([ + ...[...DYN_BUFFER_NUM_METHODS].map((m): [string, string] => [m, "scr_dyn_invoke_buffer_num"]), + ["write", "scr_dyn_invoke_buffer_write"], + ["fill", "scr_dyn_invoke_buffer_fill"], + ["toString", "scr_dyn_invoke_buffer_to_string"], + ...["indexOf", "lastIndexOf", "includes"].map((m): [string, string] => [ + m, + "scr_dyn_invoke_buffer_search", + ]), + ...[ + "toJSON", + "equals", + "compare", + "copy", + "swap16", + "swap32", + "swap64", + ...DYN_BUFFER_REFUSED_METHODS, + ].map((m): [string, string] => [m, "scr_dyn_invoke_buffer"]), +]); + const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ // DEP0040 rides the dyn-async unit's warning machinery. "async.awaitDyn", @@ -694,10 +731,10 @@ const RUNTIME_FEATURE_RULES = { handleProps: rule({ fns: ["child.propsInstall"] }), /** for await over checked-dynamic values (scr_dyn_for_await.c). */ dynForAwait: rule({ fns: ["dyn.asyncIteratorOpen", "dyn.asyncIteratorWasSync"] }), - /** Fixed-width Buffer read/write methods called on checked-dynamic - * values (scr_bytes_dyn.c). */ - dynBufferNum: rule({ - expr: (node, _tables) => node.kind === "dynInvoke" && DYN_BUFFER_NUM_METHODS.has(node.method), + /** Buffer.prototype methods called on checked-dynamic values + * (scr_bytes_dyn.c). */ + dynBuffer: rule({ + expr: (node, _tables) => node.kind === "dynInvoke" && DYN_BUFFER_METHODS.has(node.method), }), /** Builtin functions read as values (scr_builtin_values.c). */ builtinValues: rule({ fns: ["buffer.byteLenValue", "buffer.allocValue", "fs.realpathValue"] }), @@ -902,7 +939,7 @@ function noRuntimeFeatures(): RuntimeFeatures { streamDyn: false, handleProps: false, dynForAwait: false, - dynBufferNum: false, + dynBuffer: false, builtinValues: false, symbol: false, tls: false, diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index 04ce483ab..11b20a210 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -466,6 +466,18 @@ export function everyStmtChild( throw new Error("unhandled IR node"); } +/** Called with each record literal mapExprChildren rebuilds and its + * replacement, so a side table keyed by node identity (the lowering's + * key-order witness notes) follows the literal into the rewritten tree. */ +let recordLiteralRebuilt: ((from: IrExpr, to: IrExpr) => void) | null = null; + +/** Installs (or with null removes) the record-literal rebuild observer. */ +export function observeRecordLiteralRebuilds( + observer: ((from: IrExpr, to: IrExpr) => void) | null, +): void { + recordLiteralRebuilt = observer; +} + /** Copy a node with transformed immediate children; preserve all metadata. */ export function mapExprChildren( node: IrExpr, @@ -653,11 +665,14 @@ export function mapExprChildren( return { ...node, value: expr(node.value) }; case "virtualCall": return { ...node, args: mapOf(node.args, expr) }; - case "recordLit": - return { + case "recordLit": { + const rebuilt: IrExpr = { ...node, fields: node.fields.map((child) => ({ ...child!, value: expr(child.value) })), }; + if (recordLiteralRebuilt !== null) recordLiteralRebuilt(node, rebuilt); + return rebuilt; + } case "recordClone": return { ...node, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 9635ceb91..f562d20e0 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -1117,7 +1117,7 @@ function validateFunction( // helpers. They are not source-level types and never return as values. if (fn.returnType.kind === "caught") err("return type is caught", fn.loc); for (const p of fn.params) { - if (p.type.kind === "caught" && fn.name !== "%caught.dynamicValue") + if (p.type.kind === "caught" && !fn.name.startsWith("%caught.")) err(`param "${p.name}" is caught-typed`, fn.loc); if (!locals.has(p.localId)) { err(`param "${p.name}" has no local entry "${p.localId}"`, fn.loc); @@ -4080,6 +4080,8 @@ function validateFunction( const variadic = e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || + e.fn === "emitter.emitAny" || + e.fn === "emitter.emitMixed" || e.fn === "readable.new" || e.fn === "writable.new" || e.fn === "duplex.new" || @@ -5847,6 +5849,7 @@ function validateFunction( e.fn === "emitter.off" || e.fn === "emitter.onDyn" || e.fn === "emitter.onFlex" || + e.fn === "emitter.onAny" || e.fn === "stream.onDyn" || e.fn === "emitter.offDyn" || e.fn === "emitter.onData" || @@ -5901,14 +5904,27 @@ function validateFunction( if (!ok) err(`libCall emitter.listeners must return a func array`, e.loc); return true; } - if (e.fn === "emitter.emitFlex") { + if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") { if (e.args.slice(2).some((arg) => arg.type.kind !== "dyn")) { - err("libCall emitter.emitFlex payloads must be checked-dynamic", e.loc); + err(`libCall ${e.fn} payloads must be checked-dynamic`, e.loc); + } + } + if (e.fn === "emitter.emitMixed") { + const count = e.args[2]; + const k = count?.kind === "numLit" ? count.value : -1; + if (!Number.isInteger(k) || k < 0 || e.args.length < 3 + k) { + err("libCall emitter.emitMixed needs a literal argument count", e.loc); + } else if (e.args.slice(3, 3 + k).some((arg) => arg.type.kind !== "dyn")) { + err("libCall emitter.emitMixed untyped arguments must be checked-dynamic", e.loc); } } if ( e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || + e.fn === "emitter.emitAny" || + e.fn === "emitter.emitMixed" || + e.fn === "emitter.hasTyped" || + e.fn === "emitter.listenersDyn" || e.fn === "emitter.count" || e.fn === "emitter.getMax" || e.fn === "emitter.ctor" || diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 356056dd8..3e77ccced 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -63,6 +63,8 @@ interface EarlyLibraryCacheStamp { export interface EarlyLibraryNativeFeatures { backend: "llvm"; dynInvoke: boolean; + /** Buffer.prototype methods on checked-dynamic values (scr_bytes_dyn.c). */ + dynBuffer?: boolean; dynTier?: boolean; regex: boolean; assert: boolean; diff --git a/packages/compiler/src/library/prepare.ts b/packages/compiler/src/library/prepare.ts index 298bb43bb..fdaf8f506 100644 --- a/packages/compiler/src/library/prepare.ts +++ b/packages/compiler/src/library/prepare.ts @@ -1,5 +1,6 @@ import { dirname, resolve } from "node:path"; import type { CompileFailure } from "../compile-types.js"; +import { frontendDisposed } from "../frontend/dispose-hook.js"; import { fenceSpeculativeWasiFunctions, moduleWasiUnavailableSurface, @@ -585,6 +586,7 @@ export function prepareLibrary( sourceTexts = fe.sourceTexts(); } finally { fe.dispose(); + frontendDisposed(); } const mod = lowered.module!; timing("frontend-dispose"); diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index d7520ae0d..ed8d52293 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -10,6 +10,7 @@ import { } from "../backend/runtime-pack-native.js"; import { mergeNativeProfiles } from "../backend/native-tools.js"; import { setDeclarationRoot } from "../frontend/dts-paths.js"; +import { setFrontendDisposedHook } from "../frontend/dispose-hook.js"; import { FrontendServices } from "../frontend/services.js"; import { createNativeTs7Api } from "../frontend/ts7/native-api.js"; import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js"; @@ -21,8 +22,10 @@ import { buildSanitizedRuntime } from "./sanitizer.js"; import { runCompilerTask } from "./task.js"; declare function compilerNativePrivateDirectory(path: string, harden: boolean): boolean; +declare function compilerNativeCollectCycles(): void; async function main(): Promise { + setFrontendDisposedHook(() => compilerNativeCollectCycles()); const manifestPath = process.env["SCRIPTC_TOOLCHAIN"] ?? process.execPath + ".json"; let compiler: NativeCompiler | null = null; let cache: ReturnType = null; diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 23699e302..f82f4f419 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -81,7 +81,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( handleProps: false, dynForAwait: false, eventsOnceSignal: false, - dynBufferNum: false, + dynBuffer: false, builtinValues: false, warningReplace: false, stdioExtended: false, diff --git a/packages/compiler/src/text-parts.test.ts b/packages/compiler/src/text-parts.test.ts new file mode 100644 index 000000000..4a09e13d5 --- /dev/null +++ b/packages/compiler/src/text-parts.test.ts @@ -0,0 +1,49 @@ +import { constants } from "node:buffer"; +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { readTextParts, writeTextParts } from "./text-parts.js"; + +const scratch = mkdtempSync(join(tmpdir(), "scriptc-text-parts-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +test("parts join to the file's text, each within the part length", async () => { + // Multi-byte sequences and surrogate pairs straddle the 1 MiB reads and + // the 8-unit parts. + const unit = "abéc中\u{1f600}d"; + const text = unit.repeat(90_000); + const path = join(scratch, "mixed.ll"); + writeFileSync(path, text); + const parts = await readTextParts(path, 8); + expect(parts.join("")).toBe(text); + for (const part of parts) { + expect(part.length).toBeLessThanOrEqual(8); + const last = part.charCodeAt(part.length - 1); + expect(last >= 0xd800 && last <= 0xdbff).toBe(false); + } + // Each part encodes on its own, so writing them back reproduces the file. + const copy = join(scratch, "copy.ll"); + await writeTextParts(copy, parts); + expect(await readFile(copy, "utf8")).toBe(text); +}); + +test("an empty file has no parts", async () => { + const path = join(scratch, "empty.ll"); + writeFileSync(path, ""); + expect(await readTextParts(path)).toEqual([]); +}); + +test("a file past the engine's string limit reads in parts", async () => { + // A sparse file: its bytes are NUL characters, valid UTF-8, and cost no + // disk space. + const path = join(scratch, "huge.ll"); + const size = constants.MAX_STRING_LENGTH + 1; + writeFileSync(path, ""); + truncateSync(path, size); + await expect(readFile(path, "utf8")).rejects.toThrow(RangeError); + const parts = await readTextParts(path); + expect(parts.length).toBeGreaterThan(1); + expect(parts.reduce((length, part) => length + part.length, 0)).toBe(size); +}, 120_000); diff --git a/packages/compiler/src/text-parts.ts b/packages/compiler/src/text-parts.ts index a4519b0ba..1b48e72fc 100644 --- a/packages/compiler/src/text-parts.ts +++ b/packages/compiler/src/text-parts.ts @@ -1,4 +1,10 @@ +import { createReadStream } from "node:fs"; import { open } from "node:fs/promises"; +import { StringDecoder } from "node:string_decoder"; + +/** The longest part readTextParts returns, far below the engine's string + * limit. */ +const TEXT_PART_LENGTH = 64 * 1024 * 1024; /** Writes compiler text artifacts (LLVM, serialized IR) in large batches. * Programs past the engine's string limit arrive as many parts, and writing @@ -35,3 +41,43 @@ export async function writeTextParts( await file.close(); } } + +/** Reads a text artifact as parts of at most `partLength` UTF-16 units: + * cached LLVM and serialized IR of large programs exceed the engine's + * string limit as one string. The decoder carries a multi-byte sequence + * split across reads into the next part, and no part ends inside a + * surrogate pair, so the parts join to the file's text and each encodes on + * its own. */ +export async function readTextParts( + path: string, + partLength = TEXT_PART_LENGTH, +): Promise { + const decoder = new StringDecoder("utf8"); + const parts: string[] = []; + let pending: string[] = []; + let pendingLength = 0; + const flush = (): void => { + if (pendingLength === 0) return; + parts.push(pending.join("")); + pending = []; + pendingLength = 0; + }; + const add = (text: string): void => { + for (let offset = 0; offset < text.length;) { + let take = Math.min(text.length - offset, partLength - pendingLength); + // A part never ends inside a surrogate pair: writeTextParts encodes + // its batches separately. + const last = text.charCodeAt(offset + take - 1); + if (take > 1 && last >= 0xd800 && last <= 0xdbff && offset + take < text.length) take--; + pending.push(text.slice(offset, offset + take)); + pendingLength += take; + offset += take; + if (pendingLength >= partLength - 1) flush(); + } + }; + for await (const chunk of createReadStream(path, { highWaterMark: 1 << 20 })) + add(decoder.write(chunk as Buffer)); + add(decoder.end()); + flush(); + return parts; +} diff --git a/packages/compiler/src/timing.ts b/packages/compiler/src/timing.ts index 79c07cb7e..6d9f6844d 100644 --- a/packages/compiler/src/timing.ts +++ b/packages/compiler/src/timing.ts @@ -1,11 +1,20 @@ export type CompilationTiming = (phase: string, detail?: Record) => void; +/** The start of a timing and its latest phase, as `Date.now()` values. */ +export interface TimingClock { + start: number; + previous: number; +} + /** Opt-in phase durations shared by the Node seed and native compiler. - * The disabled path does not read the clock or write compiler output. */ -export function compilationTiming(): CompilationTiming { + * The disabled path does not read the clock or write compiler output. + * With `clock`, the phases continue a timing that started in another + * process, so a frontend process reports them as its parent compile would. */ +export function compilationTiming(clock?: TimingClock): CompilationTiming { if (process.env["SCRIPTC_TIMING"] !== "1") return () => {}; - const start = performance.now(); - let previous = start; + const opened = performance.now(); + const start = clock === undefined ? opened : opened - (Date.now() - clock.start); + let previous = clock === undefined ? start : opened - (Date.now() - clock.previous); return (phase, detail = {}) => { const now = performance.now(); process.stderr.write( diff --git a/packages/compiler/src/wasm/translate.ts b/packages/compiler/src/wasm/translate.ts index 4c02499ca..549b51a43 100644 --- a/packages/compiler/src/wasm/translate.ts +++ b/packages/compiler/src/wasm/translate.ts @@ -211,7 +211,7 @@ export function signatureHash(sig: string): string { } /** UTF-8 bytes of a string. */ -function utf8Bytes(text: string): number[] { +export function utf8Bytes(text: string): number[] { const out: number[] = []; for (let i = 0; i < text.length; i++) { const cp = text.codePointAt(i)!; @@ -233,7 +233,7 @@ function utf8Bytes(text: string): number[] { } /** An LLVM c"..." string body for bytes. */ -function cBytes(bytes: Uint8Array, start: number, end: number): string { +export function cBytes(bytes: Uint8Array, start: number, end: number): string { let s = ""; for (let i = start; i < end; i++) { const b = bytes[i]!; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json new file mode 100644 index 000000000..31a42c38b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4635-contract-program-lifetime-layouts.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6495-key-order-rewritten-literals.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6495-key-order-rewritten-literals.ts.json new file mode 100644 index 000000000..fef8bfb2a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6495-key-order-rewritten-literals.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6495-key-order-rewritten-literals.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6496-key-order-built-and-parsed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6496-key-order-built-and-parsed.ts.json new file mode 100644 index 000000000..24ca2fe96 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6496-key-order-built-and-parsed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6496-key-order-built-and-parsed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json new file mode 100644 index 000000000..347deebd8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6580-emitter-typed-untyped-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json new file mode 100644 index 000000000..a8252c615 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6581-emitter-untyped-error-and-meta.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json new file mode 100644 index 000000000..9cc32b856 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6582-emitter-class-expression-escapes.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json new file mode 100644 index 000000000..b570e5d43 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6583-emitter-untyped-surface.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json new file mode 100644 index 000000000..45c0a95ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6584-emitter-class-body-untyped-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json new file mode 100644 index 000000000..6e7228d5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6903-typedarray-of.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6903-typedarray-of.ts.json new file mode 100644 index 000000000..35f4badfb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6903-typedarray-of.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6903-typedarray-of.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6904-typedarray-of-loose.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6904-typedarray-of-loose.mjs.json new file mode 100644 index 000000000..ccccc5293 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6904-typedarray-of-loose.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6904-typedarray-of-loose.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6907-generic-member-defaulted-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6907-generic-member-defaulted-callback.ts.json new file mode 100644 index 000000000..bf034340e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6907-generic-member-defaulted-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6907-generic-member-defaulted-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-dyn-buffer-methods.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-dyn-buffer-methods.cjs.json new file mode 100644 index 000000000..7334e8458 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-dyn-buffer-methods.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6930-dyn-buffer-methods.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json new file mode 100644 index 000000000..b4ade9601 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6930-fs-promises-rm-options.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json new file mode 100644 index 000000000..5e0afea43 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity-names.ts.json new file mode 100644 index 000000000..f3a79fd5b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-integrity-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity.js.json new file mode 100644 index 000000000..0f4628189 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-integrity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-integrity.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-literal-tostring-template.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-literal-tostring-template.ts.json new file mode 100644 index 000000000..f2c993f6a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-literal-tostring-template.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/object-literal-tostring-template.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/prototype-tostring-conversions.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/prototype-tostring-conversions.cjs.json new file mode 100644 index 000000000..dcb13023c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/prototype-tostring-conversions.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/prototype-tostring-conversions.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json new file mode 100644 index 000000000..6a4a8b936 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/wrapped-assignment-targets.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/wrapped-assignment-targets.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts.json new file mode 100644 index 000000000..cdb86be04 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/library-matrix.mjs b/packages/runtime-pack-common/library-matrix.mjs index 667f9a34f..d9878ec7c 100644 --- a/packages/runtime-pack-common/library-matrix.mjs +++ b/packages/runtime-pack-common/library-matrix.mjs @@ -15,6 +15,7 @@ export function withLibraryRuntimeFlavors(matrix) { "scr_zlib.c", "scr_copying.c", "scr_dyn_invoke.c", + "scr_bytes_dyn.c", "scr_js_value.c", "scr_js_object.c", ]); diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index e84970708..e6b7c863a 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -61,8 +61,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_inspect.c", "inspect"], ["scr_console_native.c", "inspect"], ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], - // Fixed-width Buffer methods called on checked-dynamic values. - ["scr_bytes_dyn.c", all("dynBufferNum", "dynInvoke")], + // Buffer.prototype methods called on checked-dynamic values. + ["scr_bytes_dyn.c", all("dynBuffer", "dynInvoke")], // Builtin functions read as values in JavaScript dependencies. ["scr_builtin_values.c", "builtinValues"], ["scr_dc.c", "dc"], diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 9f8f4c6c1..8cf14fb7f 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -226,6 +226,11 @@ static pthread_cond_t scr_sa_pf_cond = PTHREAD_COND_INITIALIZER; static ScrSaPfRange scr_sa_pf_ring[SCR_SA_PF_RING]; static unsigned scr_sa_pf_head, scr_sa_pf_tail; /* under the lock */ static bool scr_sa_pf_started, scr_sa_pf_waiting; +/* The range the helper is faulting in, and the threads that wait for it to + * finish (scr_sa_pf_forget); under the lock. */ +static ScrSaPfRange scr_sa_pf_busy; +static unsigned scr_sa_pf_forgetting; +static pthread_cond_t scr_sa_pf_done = PTHREAD_COND_INITIALIZER; /* 0 = not decided yet, 1 = on, -1 = off (environment, no thread, or a * kernel without MADV_POPULATE_WRITE). */ static _Atomic(int) scr_sa_pf_state; @@ -323,8 +328,9 @@ static void *scr_sa_pf_main(void *arg) { scr_sa_pf_waiting = false; } ScrSaPfRange r = scr_sa_pf_ring[scr_sa_pf_tail++ % SCR_SA_PF_RING]; + scr_sa_pf_busy = r; pthread_mutex_unlock(&scr_sa_pf_lock); - scr_sa_pf_populate(r.lo, r.hi); + if (r.hi > r.lo) scr_sa_pf_populate(r.lo, r.hi); #ifdef SCR_SA_PF_HEAP if (scr_sa_pf_heap_mode) { (void)scr_sa_pf_heap(); @@ -332,10 +338,26 @@ static void *scr_sa_pf_main(void *arg) { } #endif pthread_mutex_lock(&scr_sa_pf_lock); + scr_sa_pf_busy = (ScrSaPfRange){NULL, NULL}; + if (scr_sa_pf_forgetting) pthread_cond_broadcast(&scr_sa_pf_done); } return NULL; } +/* Drops the requests that overlap [lo, hi) and waits for the one the helper + * is faulting in if it overlaps, so pages returned there stay returned. */ +static void scr_sa_pf_forget(char *lo, char *hi) { + pthread_mutex_lock(&scr_sa_pf_lock); + for (unsigned k = scr_sa_pf_tail; k != scr_sa_pf_head; k++) { + ScrSaPfRange *r = &scr_sa_pf_ring[k % SCR_SA_PF_RING]; + if (r->lo < hi && r->hi > lo) r->hi = r->lo; + } + scr_sa_pf_forgetting++; + while (scr_sa_pf_busy.lo < hi && scr_sa_pf_busy.hi > lo) pthread_cond_wait(&scr_sa_pf_done, &scr_sa_pf_lock); + scr_sa_pf_forgetting--; + pthread_mutex_unlock(&scr_sa_pf_lock); +} + static bool scr_sa_pf_on(void) { int s = atomic_load_explicit(&scr_sa_pf_state, memory_order_relaxed); if (s == 0) { @@ -1482,6 +1504,38 @@ void scr_sa_thread_park(void) { #endif } +#if defined(SCR_SA_TRIM) && defined(MADV_DONTNEED) +/* Returns the pages past each slice's frontier that a parking thread leaves + * faulted in but never carved: the rest of the huge page the frontier is in + * (huge pages fault in whole) and the steps faulted ahead of it. The + * thread's objects stay where they are; the next thread to claim the arena + * faults the pages in again as it carves, and they read as zero, as fresh + * pages do. Until then, a program that does its remaining work on threads + * that never claim the arena (parse threads ahead of a check on the main + * thread) does not keep them. */ +static void scr_sa_release_frontiers(void) { + uintptr_t page = (uintptr_t)sysconf(_SC_PAGESIZE); + if (page == 0 || page == (uintptr_t)-1) page = 4096; +#ifdef SCR_SA_THP + const uintptr_t ahead = SCR_SA_HUGE_PAGE; +#else + const uintptr_t ahead = page; +#endif + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + if (!scr_sa.lim[c]) continue; + const ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + char *bump = h->lim ? h->bump : scr_sa.bump[c]; + char *lim = h->lim ? h->lim : scr_sa.lim[c]; + char *end = (char *)scr_sa.base + ((size_t)(c + 1) << scr_sa.shift); + char *from = (char *)(((uintptr_t)bump + page - 1) & ~(page - 1)); + /* At most one step of `ahead` is faulted in past the limit. */ + char *to = (char *)((((uintptr_t)lim + ahead - 1) & ~(ahead - 1)) + ahead); + if (to > end) to = end; + if (to > from) scr_mem_release_ahead(from, to); + } +} +#endif + static void scr_sa_park_arena(void) { if (scr_sa_arena < 0) return; unsigned i = (unsigned)scr_sa_arena; @@ -1503,6 +1557,9 @@ static void scr_sa_park_arena(void) { return; } if (k) scr_sa_keep_merge(k); +#ifdef MADV_DONTNEED + scr_sa_release_frontiers(); +#endif if (scr_sa_trim_worth()) { if (scr_sa_start_helper(i)) { /* Later frees on this thread take the foreign path into the arena's @@ -1748,6 +1805,18 @@ void scr_mem_prefault(void *lo, void *hi) { #endif } +void scr_mem_release_ahead(void *lo, void *hi) { +#if SCR_SMALL_ALLOC && defined(SCR_SA_PREFAULT) + if (atomic_load_explicit(&scr_sa_pf_state, memory_order_relaxed) > 0) scr_sa_pf_forget(lo, hi); +#endif +#if defined(__linux__) && defined(MADV_DONTNEED) + if ((char *)hi > (char *)lo) (void)madvise(lo, (size_t)((char *)hi - (char *)lo), MADV_DONTNEED); +#else + (void)lo; + (void)hi; +#endif +} + /* Free order. Free lists are LIFO, so blocks freed in descending address * order come back in ascending order, the order the bump pointers carve * fresh memory in, and the order the program then touches them in. The diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index 042c04c65..2d8024c6d 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -442,6 +442,68 @@ static void check_trim(void) { } free(trim_blocks); } + +/* A thread that parks returns the pages faulted in past each slice's + * frontier (the rest of the frontier's huge page and the pages faulted in + * ahead), and its blocks stay; the next thread to claim the arena carves on + * from the frontier, zeroed. FRONTIER_SIZE is a class the other checks leave + * unused, and FRONTIER_BYTES carves past SCR_SA_HUGE_AFTER. */ +enum { FRONTIER_SIZE = 120, FRONTIER_BYTES = 12 << 20, FRONTIER_MORE = 4096 }; +static unsigned char **frontier_blocks; +static size_t frontier_n; +static uintptr_t frontier_hi, frontier_end; +static size_t frontier_past; + +static void *frontier_allocate(void *arg) { + (void)arg; + scr_sa_thread_starting(); + for (size_t i = 0; i < frontier_n; i++) { + unsigned char *p = scr_mem_alloc(FRONTIER_SIZE); + assert(p && owned(p)); + fill(p, FRONTIER_SIZE, (unsigned char)i); + frontier_blocks[i] = p; + if ((uintptr_t)p + FRONTIER_SIZE > frontier_hi) frontier_hi = (uintptr_t)p + FRONTIER_SIZE; + } + uintptr_t c = ((uintptr_t)frontier_blocks[0] - scr_sa.base) >> scr_sa.shift; + frontier_end = scr_sa.base + ((c + 1) << scr_sa.shift); + scr_sa_thread_shares(); + scr_sa_thread_park(); + return NULL; +} + +static void *frontier_continue(void *arg) { + (void)arg; + for (size_t i = 0; i < FRONTIER_MORE; i++) { + unsigned char *p = scr_mem_calloc(FRONTIER_SIZE); + assert(p && owned(p)); + for (size_t k = 0; k < FRONTIER_SIZE; k++) assert(p[k] == 0); + if ((uintptr_t)p >= frontier_hi) frontier_past++; + scr_mem_free(p); + } + scr_sa_thread_park(); + return NULL; +} + +static void check_release_frontiers(void) { + frontier_n = FRONTIER_BYTES / FRONTIER_SIZE; + frontier_blocks = malloc(frontier_n * sizeof *frontier_blocks); + assert(frontier_blocks); + pthread_t t; + assert(pthread_create(&t, NULL, frontier_allocate, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + scr_sa_wait_trims(); + size_t page = (size_t)sysconf(_SC_PAGESIZE); + uintptr_t from = (frontier_hi + page - 1) & ~(uintptr_t)(page - 1); + uintptr_t to = from + ((size_t)4 << 20) < frontier_end ? from + ((size_t)4 << 20) : frontier_end; + if (to > from) assert(resident_pages(from, to) == 0); + for (size_t i = 0; i < frontier_n; i++) verify(frontier_blocks[i], FRONTIER_SIZE, (unsigned char)i); + assert(pthread_create(&t, NULL, frontier_continue, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + /* The freed block comes back each time: one block past the frontier. */ + assert(frontier_past >= 1); + for (size_t i = 0; i < frontier_n; i++) scr_mem_free(frontier_blocks[i]); /* remote frees */ + free(frontier_blocks); +} #endif #if defined(SCR_WORKERS) && SCR_SMALL_ALLOC && defined(__linux__) @@ -920,6 +982,7 @@ int main(void) { check_threads(); #ifdef __linux__ check_trim(); + check_release_frontiers(); check_discard(); check_discard_delay_grows(); check_discard_waits_for_loop(); diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0762f0aaa..672621f6b 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -2900,6 +2900,19 @@ ScrPromise *scr_fsp_rm(ScrStr *path) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_rm_opts(ScrStr *path, bool recursive, bool force) { + scr_fs_rm_opts(path, recursive, force); + return scr_promise_settled_void(); +} + +/* fs.promises.rm validates the path before its options, unlike rmSync. */ +ScrPromise *scr_fsp_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, + double retry_delay) { + if (scr_fs_path_ok(path, "path") && scr_fs_rm_retry_options_ok(max_retries, retry_delay)) + scr_fs_rm_opts_retry(path, recursive, force, max_retries, retry_delay); + return scr_promise_settled_void(); +} + ScrPromise *scr_fsp_stat(ScrStr *path) { ScrStats *st = scr_fs_stat(path); return scr_promise_settled_ref(st, &scr_stats_retain_v, &scr_stats_release_v, NULL); diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 2e964e78b..7a6a76cf9 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1489,8 +1489,10 @@ ScrStr *scr_strdec_end(const ScrStr *enc, double pending) { static void scr_bytes_to_str_bounds(const ScrBytes *b, double start, double end, size_t *s0_out, size_t *e0_out) { size_t len = b->len; - size_t s0 = start <= 0 ? 0 : ((size_t)start > len ? len : (size_t)start); - size_t e0 = end <= 0 ? 0 : ((size_t)end > len ? len : (size_t)end); + /* Clamped before the conversion: NaN and values at or below 0 are 0, + * values at or past the length (Infinity included) are the length. */ + size_t s0 = !(start > 0) ? 0 : start >= (double)len ? len : (size_t)start; + size_t e0 = !(end > 0) ? 0 : end >= (double)len ? len : (size_t)end; if (e0 < s0) e0 = s0; *s0_out = s0; *e0_out = e0; @@ -2519,7 +2521,7 @@ static bool scr_enc_eq_ci(const ScrStr *raw, const char *name) { return true; } -static ScrStr *scr_bytes_normalize_encoding(const ScrStr *raw) { +ScrStr *scr_bytes_normalize_encoding(const ScrStr *raw) { static const struct { const char *from; const char *to; } map[] = { {"utf8", "utf8"}, {"utf-8", "utf8"}, {"hex", "hex"}, {"base64", "base64"}, {"base64url", "base64url"}, @@ -3204,15 +3206,61 @@ double scr_bytes_compare(const ScrBytes *src, const ScrBytes *target, double nar return sn < tn ? -1 : 1; } +/* node_buffer.cc's IndexOfBuffer/IndexOfString for the UCS2 encoding over + * an encoded needle: buffer.js clamps the byteOffset to the int32 range, + * IndexOfOffset picks the start, the search runs over whole 2-byte units of + * the haystack (an odd trailing byte of either side is ignored), and a match + * is reported at its byte position. */ +static double scr_bytes_index_of_ucs2(const ScrBytes *haystack, const ScrBytes *needle, double at, bool fwd) { + int64_t length = (int64_t)haystack->len, needle_length = (int64_t)needle->len; + /* NaN (an omitted or non-numeric byteOffset) searches everything. */ + if (at > 2147483647.0) at = 2147483647.0; + else if (at < -2147483648.0) at = -2147483648.0; + int64_t offset_i64 = at != at ? (fwd ? 0 : length) : (int64_t)trunc(at); + int64_t opt_offset; + if (offset_i64 < 0) { + if (offset_i64 + length >= 0) opt_offset = length + offset_i64; + else opt_offset = fwd || needle_length == 0 ? 0 : -1; + } else if (offset_i64 + needle_length <= length) { + opt_offset = offset_i64; + } else if (needle_length == 0) { + opt_offset = length; + } else { + opt_offset = fwd ? -1 : length - 1; + } + size_t search_end = haystack->len & ~(size_t)1; + if (needle_length == 0) return (double)(opt_offset < (int64_t)search_end ? opt_offset : (int64_t)search_end); + if (length == 0 || opt_offset <= -1) return -1; + size_t offset = (size_t)opt_offset; + if (offset >= search_end) { + if (fwd || search_end == 0) return -1; + offset = search_end - 1; + } + if ((fwd && needle->len + offset > search_end) || needle->len > search_end) return -1; + if (search_end < 2 || needle->len < 2) return -1; + size_t units = search_end / 2, needle_units = needle->len / 2, start = offset / 2; + if (units < needle_units) return -1; + size_t last = units - needle_units; + if (fwd) { + for (size_t p = start; p <= last; p++) + if (!memcmp(haystack->data + 2 * p, needle->data, 2 * needle_units)) return (double)(2 * p); + return -1; + } + for (size_t p = start < last ? start : last;; p--) { + if (!memcmp(haystack->data + 2 * p, needle->data, 2 * needle_units)) return (double)(2 * p); + if (p == 0) return -1; + } +} + /* The shared search core: needle bytes, a coerced byteOffset (trunc; NaN * means 'search everything' — 0 forward, the end backward; negatives * count from the end, and a still-negative backward search answers -1), - * and the utf16le alignment stride. */ + * and the alignment: 2 searches utf16le units (scr_bytes_index_of_ucs2). */ double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, double align, bool fwd) { SCR_BYTES_SNAPSHOT(b); SCR_BYTES_SNAPSHOT(needle); + if (align == 2) return scr_bytes_index_of_ucs2(b, needle, off, fwd); size_t len = b->len, nlen = needle->len; - size_t step = align == 2 ? 2 : 1; double o = (off != off) ? (fwd ? 0 : (double)len) : trunc(off); if (o < 0) { o += (double)len; @@ -3225,17 +3273,15 @@ double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, if (nlen > len) return -1; if (fwd) { size_t start = o > (double)len ? len : (size_t)o; - if (step == 2) start += start % 2; - for (size_t i = start; i + nlen <= len; i += step) { + for (size_t i = start; i + nlen <= len; i++) { if (memcmp(b->data + i, needle->data, nlen) == 0) return (double)i; } return -1; } size_t start = o > (double)(len - nlen) ? len - nlen : (size_t)o; - if (step == 2) start -= start % 2; - for (size_t i = start;; i -= step) { + for (size_t i = start;; i--) { if (memcmp(b->data + i, needle->data, nlen) == 0) return (double)i; - if (i < step) break; + if (i == 0) break; } return -1; } @@ -3911,3 +3957,63 @@ ScrStr *scr_bytes_join(const ScrBytes *b, const ScrStr *separator) { scr_arr_release(values); return out; } + +/* ── the checked-dynamic Buffer compare/equals validators ────────────── + * Node's argument ladders for buf.equals / buf.compare / Buffer.compare + * over dyn-boxed arguments (the invalid-input probes: string needles, + * '0' offsets, null/object range args). A well-typed dyn still computes + * the real answer — validation, not a constant fence. */ + +/* A bytes payload or the API's own ERR_INVALID_ARG_TYPE (borrowed). */ +static ScrBytes *scr_bytes_chk_u8(const ScrDyn *d, const char *argname) { + if (!scr_dyn_bytes_is(d, SCR_BYTES_U8)) { + scr_dyn_arg_type_fail(argname, "an instance of Buffer or Uint8Array", d); + return NULL; + } + return d->v.bytes; +} + +double scr_buffer_compare_chk(const ScrDyn *a, const ScrDyn *b) { + ScrBytes *b1 = scr_bytes_chk_u8(a, "buf1"); + if (!b1) return 0; + ScrBytes *b2 = scr_bytes_chk_u8(b, "buf2"); + if (!b2) return 0; + return scr_bytes_compare(b1, b2, 0, 0, 0, 0, 0); +} + +bool scr_bytes_equals_chk(const ScrBytes *recv, const ScrDyn *other) { + ScrBytes *o = scr_bytes_chk_u8(other, "otherBuffer"); + if (!o) return false; + return scr_bytes_equals(recv, o); +} + +/* One offset slot: undefined takes the Node default, non-numbers throw + * ERR_INVALID_ARG_TYPE "of type number", numbers run validateOffset. */ +static bool scr_bytes_chk_off(const ScrDyn *d, const char *name, double max, + double dflt, double *out) { + if (d->kind == SCR_DYN_UNDEF) { + *out = dflt; + return true; + } + if (d->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail(name, "of type number", d); + return false; + } + *out = d->v.num; + return scr_bytes_validate_off(name, *out, max); +} + +double scr_bytes_compare_chk(const ScrBytes *src, const ScrDyn *target, + const ScrDyn *ts, const ScrDyn *te, + const ScrDyn *ss, const ScrDyn *se) { + ScrBytes *t = scr_bytes_chk_u8(target, "target"); + if (!t) return 0; + double tsv, tev, ssv, sev; + if (!scr_bytes_chk_off(ts, "targetStart", 9007199254740991.0, 0, &tsv)) return 0; + if (!scr_bytes_chk_off(te, "targetEnd", (double)t->len, (double)t->len, &tev)) return 0; + if (!scr_bytes_chk_off(ss, "sourceStart", 9007199254740991.0, 0, &ssv)) return 0; + if (!scr_bytes_chk_off(se, "sourceEnd", (double)src->len, (double)src->len, &sev)) return 0; + /* Every slot validated or defaulted above; nargs 4 revalidates the + * now-known-good numbers (a no-op) and keeps one comparison core. */ + return scr_bytes_compare(src, t, 4, tsv, tev, ssv, sev); +} diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 8080751ad..9c81caea5 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -150,6 +150,10 @@ ScrStr *scr_strdec_write(const ScrStr *enc, double pending, const ScrBytes *chun double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk); ScrStr *scr_strdec_end(const ScrStr *enc, double pending); ScrStr *scr_strdec_encoding(const ScrDyn *enc); /* +1, or NULL with ERR_UNKNOWN_ENCODING pending */ +/* A Buffer encoding name known only at run time, Node's getEncodingOps: the + * canonical name of an encoding in any ASCII case (+1), or NULL with + * ERR_UNKNOWN_ENCODING ("Unknown encoding: ") pending. Borrows raw. */ +ScrStr *scr_bytes_normalize_encoding(const ScrStr *raw); /* `new Uint8Array(n)` / Buffer.alloc(n): zero-filled. n goes through * ToIndex like JS (NaN → 0, truncate toward zero; negative or > 2^53-1 diff --git a/packages/runtime/src/scr_bytes_dyn.c b/packages/runtime/src/scr_bytes_dyn.c index d4b29574b..280aa423c 100644 --- a/packages/runtime/src/scr_bytes_dyn.c +++ b/packages/runtime/src/scr_bytes_dyn.c @@ -1,8 +1,9 @@ -/* The fixed-width read/write methods of a Buffer held as a checked-dynamic - * value: a JavaScript dependency's `buf.writeUInt32LE(v, 0)` on a buffer it - * got back untyped (yazl). The compiler routes only the dyn method calls - * that name one of these methods here, so programs without them link - * nothing new; any other receiver takes the ordinary dispatch. */ +/* The Buffer.prototype methods of a Buffer held as a checked-dynamic value: + * a JavaScript dependency's `buf.writeUInt32LE(v, 0)` on a buffer it got back + * untyped (yazl), or `this.buf.write(s)` on a buffer a constructor function + * stored. The compiler routes only the dyn method calls that name one of + * these methods here, so programs without them link nothing new; any other + * receiver takes the ordinary dispatch. */ #include "scr_runtime.h" #include @@ -87,14 +88,500 @@ static ScrDyn *scr_bytes_dyn_num_method(ScrBytes *b, bool read, size_t at, ScrDy return scr_exc_pending() ? NULL : scr_dyn_new_num(out); } +static const ScrDyn *scr_bytes_dyn_arg(ScrDyn *const *args, size_t argc, size_t i) { + return i < argc ? args[i] : scr_dyn_undefined(); +} + +static bool scr_bytes_dyn_undef(const ScrDyn *d) { return d->kind == SCR_DYN_UNDEF; } + +static ScrDyn *scr_bytes_dyn_str(const char *text, size_t len) { + ScrStr *s = scr_str_new(text, len); + ScrDyn *out = scr_dyn_new_str(s); + scr_str_release(s); + return out; +} + +/* Node's validateOffset: a number (ERR_INVALID_ARG_TYPE otherwise), then an + * integer in [0, max] (ERR_OUT_OF_RANGE). */ +static bool scr_bytes_dyn_offset(const ScrDyn *v, const char *name, double max, double *out) { + if (v->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail(name, "of type number", v); + return false; + } + if (!scr_bytes_validate_off(name, v->v.num, max)) return false; + *out = v->v.num; + return true; +} + +/* getEncodingOps over an argument: ToString (`encoding += ''`), then the + * canonical name in any ASCII case. +1, or NULL with the exception pending. */ +static ScrStr *scr_bytes_dyn_encoding(const ScrDyn *v) { + ScrStr *raw = scr_dyn_string_coerce_js(v); + if (!raw) return NULL; + ScrStr *out = scr_bytes_normalize_encoding(raw); + scr_str_release(raw); + return out; +} + +/* Node's toInteger(n, 0) for copy's offsets: an integer stays, other finite + * numbers in the safe range floor, everything else (NaN, ±Infinity) is 0. */ +static bool scr_bytes_dyn_to_integer(const ScrDyn *v, double *out) { + double n; + if (!scr_dyn_number_coerce_js(v, &n)) return false; + *out = n == n && n >= -9007199254740991.0 && n <= 9007199254740991.0 ? floor(n) : 0; + return true; +} + +/* buf.write(string[, offset[, length]][, encoding]): buffer.js's overload + * resolution and offset checks, then the encoding, then the string type. */ +static ScrDyn *scr_bytes_dyn_write(ScrBytes *b, ScrDyn *const *args, size_t argc) { + const ScrDyn *string = scr_bytes_dyn_arg(args, argc, 0); + const ScrDyn *offset = scr_bytes_dyn_arg(args, argc, 1); + const ScrDyn *length = scr_bytes_dyn_arg(args, argc, 2); + const ScrDyn *encoding = scr_bytes_dyn_arg(args, argc, 3); + double at = 0, budget = (double)b->len; + ScrStr *enc = NULL; + if (scr_bytes_dyn_undef(offset)) { + encoding = scr_dyn_undefined(); + } else if (scr_bytes_dyn_undef(length) && offset->kind == SCR_DYN_STR) { + encoding = offset; + } else { + if (!scr_bytes_dyn_offset(offset, "offset", (double)b->len, &at)) return NULL; + budget = (double)b->len - at; + if (length->kind == SCR_DYN_STR) { + encoding = length; + } else if (!scr_bytes_dyn_undef(length)) { + double n; + if (!scr_bytes_dyn_offset(length, "length", (double)b->len, &n)) return NULL; + if (n < budget) budget = n; + } + } + if (!scr_dyn_truthy(encoding)) { + enc = scr_str_new("utf8", 4); + } else { + enc = scr_bytes_dyn_encoding(encoding); + if (!enc) return NULL; + } + if (string->kind != SCR_DYN_STR) { + scr_str_release(enc); + static const char message[] = "argument must be a string"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_ARG_TYPE"); + return NULL; + } + double written = scr_bytes_write_str(b, string->v.str, enc, at, budget, true); + scr_str_release(enc); + return scr_exc_pending() ? NULL : scr_dyn_new_num(written); +} + +/* Node's inspect quoting for the ERR_INVALID_ARG_VALUE render of a string: + * single quotes unless the text has one, then double, then backticks. */ +static ScrStr *scr_bytes_dyn_quoted(const ScrStr *text) { + char quote = '\''; + if (memchr(text->data, '\'', text->len)) quote = memchr(text->data, '"', text->len) ? '`' : '"'; + ScrJsonBuf jb; + scr_jb_init(&jb); + scr_jb_puts(&jb, "The argument 'value' is invalid. Received "); + scr_jb_putc(&jb, quote); + scr_jb_put_str(&jb, text); + scr_jb_putc(&jb, quote); + return scr_jb_finish(&jb); +} + +/* Appends Node's inspect of an ArrayBuffer at indentation level `indent`: + * the first 100 bytes as hex pairs, on one line when it fits the 80-column + * break length (reduceToSingleString), one entry per line otherwise. */ +static void scr_bytes_dyn_inspect_array_buffer(ScrJsonBuf *jb, const ScrBytes *owner, size_t indent) { + static const char digits[] = "0123456789abcdef"; + size_t len = owner->len * scr_bytes_elem_size(owner->elem); + size_t shown = len < 100 ? len : 100; + ScrJsonBuf contents; + scr_jb_init(&contents); + scr_jb_puts(&contents, "[Uint8Contents]: <"); + for (size_t i = 0; i < shown; i++) { + uint8_t byte; + scr_bytes_read(owner, i, &byte, 1); + if (i) scr_jb_putc(&contents, ' '); + scr_jb_putc(&contents, digits[byte >> 4]); + scr_jb_putc(&contents, digits[byte & 15]); + } + if (len > shown) { + char more[48]; + snprintf(more, sizeof more, " ... %zu more byte%s", len - shown, len - shown > 1 ? "s" : ""); + scr_jb_puts(&contents, more); + } + scr_jb_putc(&contents, '>'); + ScrStr *first = scr_jb_finish(&contents); + char second[40]; + int second_len = snprintf(second, sizeof second, "[byteLength]: %zu", len); + /* isBelowBreakLength with two entries, start = 2 + indentation + "ArrayBuffer {" + 10. */ + size_t total = 2 + (2 + indent + 13 + 10) + first->len + (size_t)second_len; + if (total <= 80) { + scr_jb_puts(jb, "ArrayBuffer { "); + scr_jb_put_str(jb, first); + scr_jb_puts(jb, ", "); + scr_jb_puts(jb, second); + scr_jb_puts(jb, " }"); + } else { + scr_jb_puts(jb, "ArrayBuffer {\n"); + for (size_t i = 0; i < indent + 2; i++) scr_jb_putc(jb, ' '); + scr_jb_put_str(jb, first); + scr_jb_puts(jb, ",\n"); + for (size_t i = 0; i < indent + 2; i++) scr_jb_putc(jb, ' '); + scr_jb_puts(jb, second); + scr_jb_putc(jb, '\n'); + for (size_t i = 0; i < indent; i++) scr_jb_putc(jb, ' '); + scr_jb_putc(jb, '}'); + } + scr_str_release(first); +} + +/* ERR_INVALID_ARG_VALUE for an empty ArrayBufferView fill value: Node's + * inspect of the view (a Buffer, an empty typed array, or a DataView with + * its buffer), cut to 128 characters followed by "...". */ +static ScrStr *scr_bytes_dyn_empty_view_message(const ScrDyn *value) { + const ScrBytes *view = value->v.bytes; + ScrJsonBuf jb; + scr_jb_init(&jb); + if (value->buffer) { + scr_jb_puts(&jb, ""); + } else if (!view->is_data_view) { + scr_jb_puts(&jb, scr_bytes_elem_name(view->elem)); + scr_jb_puts(&jb, "(0) []"); + } else { + const ScrBytes *owner = view->backing ? view->backing : view; + char line[64]; + snprintf(line, sizeof line, "DataView {\n [byteLength]: 0,\n [byteOffset]: %zu,\n [buffer]: ", + (size_t)(view->data - owner->data)); + scr_jb_puts(&jb, line); + scr_bytes_dyn_inspect_array_buffer(&jb, owner, 2); + scr_jb_puts(&jb, "\n}"); + } + ScrStr *inspected = scr_jb_finish(&jb); + ScrJsonBuf out; + scr_jb_init(&out); + scr_jb_puts(&out, "The argument 'value' is invalid. Received "); + if (inspected->len > 128) { + ScrStr *head = scr_str_new(inspected->data, 128); + scr_jb_put_str(&out, head); + scr_str_release(head); + scr_jb_puts(&out, "..."); + } else { + scr_jb_put_str(&out, inspected); + } + scr_str_release(inspected); + return scr_jb_finish(&out); +} + +/* buf.fill(value[, offset[, end]][, encoding]): buffer.js's _fill. A string + * takes its encoding from the first string among offset and end; numbers and + * other non-view values fill with ToUint32(value) & 255. Answers the receiver. */ +static ScrDyn *scr_bytes_dyn_fill(ScrDyn *recv, ScrDyn *const *args, size_t argc) { + ScrBytes *b = recv->v.bytes; + const ScrDyn *value = scr_bytes_dyn_arg(args, argc, 0); + const ScrDyn *offset = scr_bytes_dyn_arg(args, argc, 1); + const ScrDyn *end = scr_bytes_dyn_arg(args, argc, 2); + ScrStr *enc = NULL; + bool whole = false; + if (value->kind == SCR_DYN_STR) { + const ScrDyn *encoding = scr_bytes_dyn_arg(args, argc, 3); + if (scr_bytes_dyn_undef(offset) || offset->kind == SCR_DYN_STR) { + encoding = offset; + whole = true; + } else if (end->kind == SCR_DYN_STR) { + encoding = end; + end = scr_dyn_undefined(); + } + if (encoding->kind == SCR_DYN_UNDEF || encoding->kind == SCR_DYN_NULL || + (encoding->kind == SCR_DYN_STR && encoding->v.str->len == 0)) { + enc = scr_str_new("utf8", 4); + } else if (encoding->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("encoding", "of type string", encoding); + return NULL; + } else { + enc = scr_bytes_normalize_encoding(encoding->v.str); + if (!enc) return NULL; + } + } + double from = 0, to = (double)b->len; + if (!whole && !scr_bytes_dyn_undef(offset)) { + if (!scr_bytes_dyn_offset(offset, "offset", 9007199254740991.0, &from) || + (!scr_bytes_dyn_undef(end) && !scr_bytes_dyn_offset(end, "end", (double)b->len, &to))) { + scr_str_release(enc); + return NULL; + } + if (from >= to) { + scr_str_release(enc); + return scr_dyn_retain(recv); + } + } + ScrBytes *out; + if (enc) { + const ScrStr *text = value->v.str; + ScrBytes *pattern = scr_bytes_from_str(text, enc); + scr_str_release(enc); + if (text->len > 0 && pattern->len == 0) { + scr_bytes_release(pattern); + ScrStr *message = scr_bytes_dyn_quoted(text); + scr_throw_error_msg_code(SCR_ERR_TYPE, message->data, message->len, "ERR_INVALID_ARG_VALUE"); + scr_str_release(message); + return NULL; + } + /* An empty string fills with zeros. */ + out = text->len == 0 ? scr_bytes_fill_num(b, 0, 2, from, to) : scr_bytes_fill(b, pattern, 2, from, to); + scr_bytes_release(pattern); + } else if (value->kind == SCR_DYN_BYTES) { + /* Any ArrayBufferView repeats its raw bytes: a byte view over the + * value's storage (a typed array of wider elements, or a DataView). */ + const ScrBytes *view = value->v.bytes; + if (view->len == 0) { + ScrStr *message = scr_bytes_dyn_empty_view_message(value); + scr_throw_error_msg_code(SCR_ERR_TYPE, message->data, message->len, "ERR_INVALID_ARG_VALUE"); + scr_str_release(message); + return NULL; + } + ScrBytes pattern = { .rc = 1, + .len = view->len * scr_bytes_elem_size(view->elem), + .elem = SCR_BYTES_U8, + .data = view->data, + .backing = NULL, + .shared = view->shared }; + out = scr_bytes_fill(b, &pattern, 2, from, to); + } else { + double n; + if (!scr_dyn_number_coerce_js(value, &n)) return NULL; + out = scr_bytes_fill_num(b, n, 2, from, to); + } + if (!out) return NULL; + scr_bytes_release(out); + return scr_dyn_retain(recv); +} +/* buf.toString(encoding, start, end): the window clamps to [0, length] + * with NaN as 0 (infinite and out-of-range values included), an empty + * window answers "" before the encoding resolves, and an undefined encoding + * is utf8. */ +static ScrDyn *scr_bytes_dyn_to_string(ScrBytes *b, ScrDyn *const *args, size_t argc) { + const ScrDyn *encoding = scr_bytes_dyn_arg(args, argc, 0); + const ScrDyn *start = scr_bytes_dyn_arg(args, argc, 1); + const ScrDyn *end = scr_bytes_dyn_arg(args, argc, 2); + const double len = (double)b->len; + double from = NAN, to = len; + if (!scr_bytes_dyn_undef(start) && !scr_dyn_number_coerce_js(start, &from)) return NULL; + if (!scr_bytes_dyn_undef(end) && !scr_dyn_number_coerce_js(end, &to)) return NULL; + /* `if (start <= 0) start = 0; else if (start >= len) return ''; else + * start = MathTrunc(start) || 0`, then `end > len` clamps to the length + * and other ends truncate (NaN as 0), so both stay within [0, len]. */ + if (from <= 0) from = 0; + else if (from >= len) return scr_bytes_dyn_str("", 0); + else from = from == from ? trunc(from) : 0; + if (to > len) to = len; + else to = to == to ? trunc(to) : 0; + if (to <= from) return scr_bytes_dyn_str("", 0); + ScrStr *enc = scr_bytes_dyn_undef(encoding) ? scr_str_new("utf8", 4) : scr_bytes_dyn_encoding(encoding); + if (!enc) return NULL; + ScrStr *text = scr_bytes_to_str_range(b, enc, from, to); + scr_str_release(enc); + ScrDyn *out = scr_dyn_new_str(text); + scr_str_release(text); + return out; +} + +/* buf.toJSON(): { type: 'Buffer', data: [...bytes] }. */ +static ScrDyn *scr_bytes_dyn_to_json(const ScrBytes *b) { + ScrDyn *data = scr_dyn_new_arr(); + for (size_t i = 0; i < b->len; i++) scr_dyn_arr_push(data, scr_dyn_new_num(scr_bytes_get(b, (double)i))); + ScrDyn *out = scr_dyn_new_obj(); + scr_dyn_obj_set(out, "type", 4, scr_bytes_dyn_str("Buffer", 6)); + scr_dyn_obj_set(out, "data", 4, data); + return out; +} + +/* buf.copy(target[, targetStart[, sourceStart[, sourceEnd]]]): buffer.js's + * _copy offsets (toInteger, then the >= 0 and source-length checks) into a + * Buffer or Uint8Array. Other views have no lowering here. */ +static ScrDyn *scr_bytes_dyn_copy(const ScrBytes *b, ScrDyn *const *args, size_t argc) { + const ScrDyn *target = scr_bytes_dyn_arg(args, argc, 0); + if (target->kind != SCR_DYN_BYTES) { + scr_dyn_arg_type_fail("target", "an instance of Buffer or Uint8Array", target); + return NULL; + } + if (target->v.bytes->is_data_view || target->v.bytes->elem != SCR_BYTES_U8) { + static const char message[] = + "'Buffer.prototype.copy' into a view that is not a Buffer or Uint8Array on a dynamic value is not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + return NULL; + } + double offsets[3] = { 0, 0, (double)b->len }; + for (size_t i = 0; i < 3; i++) { + const ScrDyn *v = scr_bytes_dyn_arg(args, argc, i + 1); + if (!scr_bytes_dyn_undef(v) && !scr_bytes_dyn_to_integer(v, &offsets[i])) return NULL; + } + return scr_dyn_new_num(scr_bytes_copy_into(b, target->v.bytes, 3, offsets[0], offsets[1], offsets[2])); +} + +/* Whether an encoding name is one of the utf16le spellings, in any ASCII case. */ +static bool scr_bytes_dyn_is_ucs2(const ScrStr *raw) { + static const char *const names[] = { "utf16le", "utf-16le", "ucs2", "ucs-2" }; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) { + size_t n = strlen(names[i]); + if (raw->len != n) continue; + size_t k = 0; + for (; k < n; k++) { + char c = raw->data[k]; + if (c >= 'A' && c <= 'Z') c = (char)(c + ('a' - 'A')); + if (c != names[i][k]) break; + } + if (k == n) return true; + } + return false; +} + +/* buf.indexOf/lastIndexOf/includes(value[, byteOffset][, encoding]): + * buffer.js's bidirectionalIndexOf. A string byteOffset is the encoding; + * the offset clamps to the int32 range, and NaN searches everything. */ +static ScrDyn *scr_bytes_dyn_index_of(const ScrBytes *b, ScrDyn *const *args, size_t argc, bool fwd, + bool includes) { + const ScrDyn *value = scr_bytes_dyn_arg(args, argc, 0); + const ScrDyn *offset = scr_bytes_dyn_arg(args, argc, 1); + const ScrDyn *encoding = scr_bytes_dyn_arg(args, argc, 2); + double at = NAN; + if (offset->kind == SCR_DYN_STR) { + encoding = offset; + } else if (!scr_bytes_dyn_undef(offset)) { + if (!scr_dyn_number_coerce_js(offset, &at)) return NULL; + if (at > 2147483647.0) at = 2147483647.0; + else if (at < -2147483648.0) at = -2147483648.0; + } + double found; + if (value->kind == SCR_DYN_NUM) { + found = scr_bytes_index_of_num(b, value->v.num, at, fwd); + } else { + ScrStr *raw = NULL; + if (!scr_bytes_dyn_undef(encoding)) { + raw = scr_dyn_string_coerce_js(encoding); + if (!raw) return NULL; + } + if (value->kind == SCR_DYN_STR) { + /* An unknown encoding throws for a string needle. */ + ScrStr *enc = raw ? scr_bytes_normalize_encoding(raw) : scr_str_new("utf8", 4); + if (raw) scr_str_release(raw); + if (!enc) return NULL; + bool ucs2 = enc->len == 7 && !memcmp(enc->data, "utf16le", 7); + ScrBytes *needle = scr_bytes_from_str(value->v.str, enc); + found = scr_bytes_index_of(b, needle, at, ucs2 ? 2 : 1, fwd); + scr_bytes_release(needle); + scr_str_release(enc); + } else if (value->kind == SCR_DYN_BYTES && !value->v.bytes->is_data_view && + value->v.bytes->elem == SCR_BYTES_U8) { + /* A Buffer needle searches 2-byte units under a utf16le encoding and + * bytes under any other, an unknown name included. */ + bool ucs2 = raw && scr_bytes_dyn_is_ucs2(raw); + if (raw) scr_str_release(raw); + found = scr_bytes_index_of(b, value->v.bytes, at, ucs2 ? 2 : 1, fwd); + } else { + if (raw) scr_str_release(raw); + scr_dyn_arg_type_fail("value", "one of type number or string or an instance of Buffer or Uint8Array", value); + return NULL; + } + } + return includes ? scr_dyn_new_bool(found != -1) : scr_dyn_new_num(found); +} + +/* Buffer.prototype members this dispatch does not run: an explicit refusal + * instead of the "is not a function" a missing method would give. */ +static bool scr_bytes_dyn_refused(const char *method) { + static const char *const prefixes[] = { "readBig", "writeBig", "readUInt", "readUint", "readInt", "writeUInt", + "writeUint", "writeInt", "readFloat", "readDouble", "writeFloat", + "writeDouble" }; + static const char *const names[] = { "asciiSlice", "base64Slice", "base64urlSlice", "latin1Slice", "hexSlice", + "ucs2Slice", "utf8Slice", "asciiWrite", "base64Write", "base64urlWrite", + "latin1Write", "hexWrite", "ucs2Write", "utf8Write", "inspect" }; + for (size_t i = 0; i < sizeof prefixes / sizeof prefixes[0]; i++) + if (!strncmp(method, prefixes[i], strlen(prefixes[i]))) return true; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) + if (!strcmp(method, names[i])) return true; + return false; +} + +static bool scr_bytes_dyn_buffer(const ScrDyn *recv) { + return recv->kind == SCR_DYN_BYTES && recv->buffer && !recv->v.bytes->is_data_view; +} + +/* One entry per method family, so a program links only the families it + * names: each runs its methods on a Buffer receiver and gives any other + * receiver the ordinary dispatch. */ ScrDyn *scr_dyn_invoke_buffer_num(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what) { - if (recv->kind == SCR_DYN_BYTES && recv->buffer && !recv->v.bytes->is_data_view) { + if (scr_bytes_dyn_buffer(recv)) { bool read = !strncmp(method, "read", 4); const char *rest = method + (read ? 4 : 5); for (size_t at = 0; at < sizeof scr_bytes_dyn_names / sizeof scr_bytes_dyn_names[0]; at++) { - if (!strcmp(rest, scr_bytes_dyn_names[at].name)) - return scr_bytes_dyn_num_method(recv->v.bytes, read, at, args, argc); + if (!strcmp(rest, scr_bytes_dyn_names[at].name)) return scr_bytes_dyn_num_method(recv->v.bytes, read, at, args, argc); + } + } + return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); +} + +ScrDyn *scr_dyn_invoke_buffer_write(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (scr_bytes_dyn_buffer(recv)) return scr_bytes_dyn_write(recv->v.bytes, args, argc); + return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); +} + +ScrDyn *scr_dyn_invoke_buffer_fill(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (scr_bytes_dyn_buffer(recv)) return scr_bytes_dyn_fill(recv, args, argc); + return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); +} + +ScrDyn *scr_dyn_invoke_buffer_to_string(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (scr_bytes_dyn_buffer(recv)) return scr_bytes_dyn_to_string(recv->v.bytes, args, argc); + return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); +} + +/* indexOf, lastIndexOf and includes. */ +ScrDyn *scr_dyn_invoke_buffer_search(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (scr_bytes_dyn_buffer(recv)) { + bool includes = !strcmp(method, "includes"); + return scr_bytes_dyn_index_of(recv->v.bytes, args, argc, strcmp(method, "lastIndexOf") != 0, includes); + } + return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); +} + +/* toJSON, equals, compare, copy, swap16/32/64, and the refused members. */ +ScrDyn *scr_dyn_invoke_buffer(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + if (scr_bytes_dyn_buffer(recv)) { + ScrBytes *b = recv->v.bytes; + if (!strcmp(method, "toJSON")) return scr_bytes_dyn_to_json(b); + if (!strcmp(method, "equals")) { + bool same = scr_bytes_equals_chk(b, scr_bytes_dyn_arg(args, argc, 0)); + return scr_exc_pending() ? NULL : scr_dyn_new_bool(same); + } + if (!strcmp(method, "compare")) { + double order = scr_bytes_compare_chk(b, scr_bytes_dyn_arg(args, argc, 0), scr_bytes_dyn_arg(args, argc, 1), + scr_bytes_dyn_arg(args, argc, 2), scr_bytes_dyn_arg(args, argc, 3), + scr_bytes_dyn_arg(args, argc, 4)); + return scr_exc_pending() ? NULL : scr_dyn_new_num(order); + } + if (!strcmp(method, "copy")) return scr_bytes_dyn_copy(b, args, argc); + if (!strcmp(method, "swap16") || !strcmp(method, "swap32") || !strcmp(method, "swap64")) { + ScrBytes *swapped = scr_bytes_swap(b, method[4] == '1' ? 2 : method[4] == '3' ? 4 : 8); + if (!swapped) return NULL; + scr_bytes_release(swapped); + return scr_dyn_retain(recv); + } + if (scr_bytes_dyn_refused(method)) { + ScrJsonBuf jb; + scr_jb_init(&jb); + scr_jb_puts(&jb, "'Buffer.prototype."); + scr_jb_puts(&jb, method); + scr_jb_puts(&jb, "' on a dynamic value is not supported yet"); + ScrStr *message = scr_jb_finish(&jb); + scr_throw_error_msg(SCR_ERR_ERROR, message->data, message->len); + scr_str_release(message); + return NULL; } } return scr_dyn_invoke_prepared(recv, callee, method, args, argc, what); diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index c852388e9..eb3661fc6 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1368,66 +1368,6 @@ ScrBytes *scr_buffer_from_dyn_ref_pooled(const ScrDyn *value, const ScrStr *enco return scr_buffer_from_dyn_pooled(value, encoding); } -/* ── the checked-dynamic Buffer compare/equals validators ────────────── - * Node's argument ladders for buf.equals / buf.compare / Buffer.compare - * over dyn-boxed arguments (the invalid-input probes: string needles, - * '0' offsets, null/object range args). A well-typed dyn still computes - * the real answer — validation, not a constant fence. */ - -/* A bytes payload or the API's own ERR_INVALID_ARG_TYPE (borrowed). */ -static ScrBytes *scr_bytes_chk_u8(const ScrDyn *d, const char *argname) { - if (!scr_dyn_bytes_is(d, SCR_BYTES_U8)) { - scr_dyn_arg_type_fail(argname, "an instance of Buffer or Uint8Array", d); - return NULL; - } - return d->v.bytes; -} - -double scr_buffer_compare_chk(const ScrDyn *a, const ScrDyn *b) { - ScrBytes *b1 = scr_bytes_chk_u8(a, "buf1"); - if (!b1) return 0; - ScrBytes *b2 = scr_bytes_chk_u8(b, "buf2"); - if (!b2) return 0; - return scr_bytes_compare(b1, b2, 0, 0, 0, 0, 0); -} - -bool scr_bytes_equals_chk(const ScrBytes *recv, const ScrDyn *other) { - ScrBytes *o = scr_bytes_chk_u8(other, "otherBuffer"); - if (!o) return false; - return scr_bytes_equals(recv, o); -} - -/* One offset slot: undefined takes the Node default, non-numbers throw - * ERR_INVALID_ARG_TYPE "of type number", numbers run validateOffset. */ -static bool scr_bytes_chk_off(const ScrDyn *d, const char *name, double max, - double dflt, double *out) { - if (d->kind == SCR_DYN_UNDEF) { - *out = dflt; - return true; - } - if (d->kind != SCR_DYN_NUM) { - scr_dyn_arg_type_fail(name, "of type number", d); - return false; - } - *out = d->v.num; - return scr_bytes_validate_off(name, *out, max); -} - -double scr_bytes_compare_chk(const ScrBytes *src, const ScrDyn *target, - const ScrDyn *ts, const ScrDyn *te, - const ScrDyn *ss, const ScrDyn *se) { - ScrBytes *t = scr_bytes_chk_u8(target, "target"); - if (!t) return 0; - double tsv, tev, ssv, sev; - if (!scr_bytes_chk_off(ts, "targetStart", 9007199254740991.0, 0, &tsv)) return 0; - if (!scr_bytes_chk_off(te, "targetEnd", (double)t->len, (double)t->len, &tev)) return 0; - if (!scr_bytes_chk_off(ss, "sourceStart", 9007199254740991.0, 0, &ssv)) return 0; - if (!scr_bytes_chk_off(se, "sourceEnd", (double)src->len, (double)src->len, &sev)) return 0; - /* Every slot validated or defaulted above; nargs 4 revalidates the - * now-known-good numbers (a no-op) and keeps one comparison core. */ - return scr_bytes_compare(src, t, 4, tsv, tev, ssv, sev); -} - /* new Buffer(number, encoding) — the deprecated ctor's string arm with a * non-string first argument: Node's exact ERR_INVALID_ARG_TYPE (the * throwing path never fires DEP0005, so compiled silence matches). */ diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 93180deca..ce4dfa5fa 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -2195,6 +2195,23 @@ void scr_collect_cycles(void) { } } +/* The full sweep, for a caller that knows a phase's data has just died (a + * compiler dropping its frontend). Such data is mostly cyclic, so reference + * counting frees little of it and the schedule cannot see it die. The + * growth fractions the phase widened, while its live data kept passes from + * finding anything, and the death rule's backoff then start over from the + * heap the sweep leaves, as after a productive pass. Otherwise the next + * phase's garbage would accumulate until the heap grew to that widened + * fraction. */ +void scr_collect_cycles_phase(void) { + scr_collect_cycles(); + for (unsigned gen = SCR_CYC_MATURE; gen < SCR_CYC_NGENS; gen++) { + scr_cyc_growth_shift[gen] = 0; + scr_cyc_growth_streak[gen] = 0; + } + scr_cyc_death_shift = 0; +} + /* Worker TLS becomes unreachable when its OS thread returns. Release the * reusable traversal buffers after all context-owned roots are retired. */ void scr_cyc_context_cleanup(void) { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index a31f7c49e..0783ff705 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -671,6 +671,11 @@ double scr_dyn_native_date_value(const ScrDyn *value); ScrDyn *scr_dyn_native_regex(ScrRegex *value); bool scr_dyn_native_regex_is(const ScrDyn *value); ScrDyn *scr_dyn_freeze(ScrDyn *value); +/* scr_dyn_freeze of a value that is not a function (objects, arrays, + * primitives), for runtime callers freezing data they built. It never + * creates a function's property table, so it adds no dynamic-tier code to + * the programs that link it. */ +ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_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 */ @@ -1153,10 +1158,15 @@ bool scr_dyn_strict_eq(const ScrDyn *a, const ScrDyn *b); * result owned (+1). MAY THROW (NULL with the exception pending). */ ScrDyn *scr_dyn_prepare_method(ScrDyn *recv, const char *method); ScrDyn *scr_dyn_invoke_prepared(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); -/* scr_dyn_invoke_prepared for the fixed-width Buffer read/write method names - * (scr_bytes_dyn.c): a Buffer receiver runs the method, any other takes the - * ordinary dispatch. */ +/* scr_dyn_invoke_prepared for Buffer.prototype method names (scr_bytes_dyn.c), + * one entry per method family: a Buffer receiver runs the method, any other + * takes the ordinary dispatch. */ ScrDyn *scr_dyn_invoke_buffer_num(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); +ScrDyn *scr_dyn_invoke_buffer_write(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); +ScrDyn *scr_dyn_invoke_buffer_fill(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); +ScrDyn *scr_dyn_invoke_buffer_to_string(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); +ScrDyn *scr_dyn_invoke_buffer_search(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); +ScrDyn *scr_dyn_invoke_buffer(ScrDyn *recv, ScrDyn *callee, const char *method, ScrDyn *const *args, size_t argc, const char *what); ScrDyn *scr_dyn_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc, const char *what); /* Keyed read on a FUNC node: the own-property table first (defineProperties * writes land there), then "name" (the box's best-effort static name; "" diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 782fab829..ffe1d0330 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -42,9 +42,12 @@ * the emitted sc_ds walker exists only in programs that spell * String(unknown) themselves). Same rules: arrays join with "," and * null/undefined elements print empty, the checked-dynamic tree's error encoding renders - * "name: message", functions render the native-code form, plain objects - * are [object Object]. */ -static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { + * "name: message", functions render the native-code form. With + * `to_string` (join and the default sort order, which run ToString), other + * objects convert with ToPrimitive's string hint, calling a toString or + * valueOf of their own or of their prototypes, and an exception it throws + * stays pending; without it (error messages), they are [object Object]. */ +static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d, bool to_string) { switch (d->kind) { case SCR_DYN_SYMBOL: { ScrStr *s = scr_dyn_string_coerce(d); @@ -69,16 +72,21 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { case SCR_DYN_STR: scr_jb_write(b, d->v.str->data, d->v.str->len); return; - case SCR_DYN_ARR: - for (size_t i = 0; i < d->v.arr.len; i++) { + case SCR_DYN_ARR: { + /* The length is read once, as join reads it; an element's toString can + * append to or shrink the array (scr_dyn_arr_at answers undefined past + * its current end). */ + size_t len = d->v.arr.len; + for (size_t i = 0; i < len; i++) { if (i > 0) scr_jb_putc(b, ','); ScrDyn *e = scr_dyn_arr_at(d, (double)i); if (!e) return; - if (e->kind != SCR_DYN_UNDEF && e->kind != SCR_DYN_NULL) scr_dyn_display_buf(b, e); + if (e->kind != SCR_DYN_UNDEF && e->kind != SCR_DYN_NULL) scr_dyn_display_buf(b, e, to_string); scr_dyn_release(e); if (scr_exc_pending()) return; } return; + } case SCR_DYN_OBJ: { const ScrDyn *marker = scr_dyn_obj_get(d, "%error", 6); if (marker) { @@ -88,6 +96,13 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { scr_str_release(text); return; } + if (to_string) { + ScrStr *text = scr_dyn_string_coerce_js(d); + if (!text) return; + scr_jb_write(b, text->data, text->len); + scr_str_release(text); + return; + } scr_jb_puts(b, "[object Object]"); return; } @@ -129,7 +144,7 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { case SCR_DYN_TYPED_REF: { ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); - scr_dyn_display_buf(b, materialized); + scr_dyn_display_buf(b, materialized, to_string); scr_dyn_release(materialized); return; } @@ -142,7 +157,7 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { ScrStr *scr_dyn_display(const ScrDyn *d) { ScrJsonBuf b; scr_jb_init(&b); - scr_dyn_display_buf(&b, d); + scr_dyn_display_buf(&b, d, false); return scr_jb_finish(&b); } @@ -217,7 +232,7 @@ static bool dyn_cb_check(ScrDyn *const *args, size_t argc) { ScrJsonBuf b; scr_jb_init(&b); - scr_dyn_display_buf(&b, cb); + scr_dyn_display_buf(&b, cb, false); scr_jb_puts(&b, " is not a function"); scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); return false; @@ -564,7 +579,7 @@ static ScrDyn *scr_dynh_dispatch(ScrDyn *recv, const char *method, ScrDyn *const static ScrStr *dyn_sort_str(const ScrDyn *e) { ScrJsonBuf b; scr_jb_init(&b); - scr_dyn_display_buf(&b, e); + scr_dyn_display_buf(&b, e, true); return scr_jb_finish(&b); } static bool dyn_sort_less(void *ctx, uint64_t xs, uint64_t ys, bool *failed) { @@ -581,7 +596,13 @@ static bool dyn_sort_less(void *ctx, uint64_t xs, uint64_t ys, bool *failed) { return v < 0; } ScrStr *xstr = dyn_sort_str(x); - ScrStr *ystr = dyn_sort_str(y); + ScrStr *ystr = scr_exc_pending() ? NULL : dyn_sort_str(y); + if (scr_exc_pending()) { + scr_str_release(xstr); + if (ystr) scr_str_release(ystr); + *failed = true; + return false; + } int c = scr_str_cmp_u16(xstr, ystr); scr_str_release(xstr); scr_str_release(ystr); @@ -1194,15 +1215,20 @@ static ScrDyn *scr_dyn_invoke_impl( scr_jb_init(&b); ScrStr *separator = argc > 0 && args[0]->kind != SCR_DYN_UNDEF ? scr_dyn_string_coerce_js(args[0]) : scr_str_new(",", 1); - if (!separator) { free(b.data); return NULL; } + if (!separator) return NULL; /* nothing written yet */ for (size_t i = 0; i < len; i++) { if (i > 0) scr_jb_write(&b, separator->data, separator->len); ScrDyn *e = scr_dyn_arr_at(recv, (double)i); if (e) { - if (e->kind != SCR_DYN_UNDEF && e->kind != SCR_DYN_NULL) scr_dyn_display_buf(&b, e); + if (e->kind != SCR_DYN_UNDEF && e->kind != SCR_DYN_NULL) scr_dyn_display_buf(&b, e, true); scr_dyn_release(e); } - if (!e || scr_exc_pending()) { scr_str_release(separator); free(b.data); return NULL; } + if (!e || scr_exc_pending()) { + /* The buffer's bytes live in a string allocation: discard it as one. */ + scr_str_release(scr_jb_finish(&b)); + scr_str_release(separator); + return NULL; + } } scr_str_release(separator); ScrStr *joined = scr_jb_finish(&b); @@ -1463,7 +1489,7 @@ static ScrDyn *scr_dyn_invoke_impl( ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "The comparison function must be either a function or undefined: "); - scr_dyn_display_buf(&b, cmp); + scr_dyn_display_buf(&b, cmp, false); scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); return NULL; } @@ -2181,7 +2207,7 @@ static ScrDyn *scr_dyn_define_props_internal(ScrDyn *target, ScrDyn *descs, ScrD ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "Property description must be an object: "); - scr_dyn_display_buf(&b, descriptor); + scr_dyn_display_buf(&b, descriptor, false); scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); scr_dyn_release(descriptor); goto fail; diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 4ac7abdf1..c11b7897d 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -27,8 +27,10 @@ * site's writes. Internal (meta-event) emits reuse the same variadic * entry, passing the event NAME as the one argument — meta listeners are * fenced to arity <= 1, so no adapter ever reads the listener argument - * Node would pass second. Computed names disjoint from internal events use a - * checked-dynamic callback and an exact-arity argument vector instead. + * Node would pass second. Listeners registered as checked-dynamic functions + * keep their checked-dynamic thunk, which untyped emits call with the exact + * argument vector; an entry can hold both forms, so typed and untyped + * registrations and emits of one name share one list. * * The special 'error' event routes through scr_emitter_emit_error: with no * 'error' listener the Error payload THROWS through the exception cell @@ -70,11 +72,19 @@ typedef struct ScrEeEntry { * listeners()/rawListeners() answer it — Node's identity semantics. */ ScrClosure *orig; ScrEeInvoke inv; - /* Computed-name registration: the original closure and its checked- - * dynamic thunk, called with the exact argument count. Holding the - * closure directly keeps its capture edges visible to cycle collection. */ + /* Checked-dynamic registration (computed names, and every listener + * registered as a dynamic function value): the original closure and its + * checked-dynamic thunk, called with the exact argument count of an + * untyped emit. Holding the closure directly keeps its capture edges + * visible to cycle collection. An entry may carry both forms: a typed + * emit calls `inv`, an untyped one calls the thunk with the arguments as + * given. The descriptor rebuilds the function value for listeners() and + * the meta events. */ ScrClosure *flex_cb; ScrDynThunk flex_invoke; + const char *flex_sig; + const char *flex_name; + uint32_t flex_arity; bool once; bool fired; /* a once entry that already ran (snapshot skip rule) */ uint32_t refs; @@ -355,6 +365,8 @@ void scr_emitter_release_v(void *em) { scr_emitter_release(em); } /* ── the meta events ('newListener' / 'removeListener') ──────────────── */ static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...); +static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, + int typed, ...); static void scr_ee_symbol_name_fence(const char *what) { char message[160]; @@ -363,10 +375,62 @@ static void scr_ee_symbol_name_fence(const char *what) { scr_throw_error(SCR_ERR_ERROR, scr_str_new(message, strlen(message))); } -/* Fires a meta event with the affected event's NAME as the argument - * (listeners are fenced to arity <= 1, so the listener-function argument - * Node passes second is never read). */ -static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name) { +/* Whether any entry of the bucket was registered as a checked-dynamic + * function (it can take the untyped argument vector). */ +static bool scr_ee_bucket_has_flex(const ScrEeBucket *b) { + for (size_t i = 0; b && i < b->n; i++) { + if (b->ls[i]->flex_invoke) return true; + } + return false; +} + +/* The checked-dynamic function value of a dynamically registered entry + * (+1), or NULL for a typed listener. Identity lives in the closure, so the + * rebuilt box compares equal to the value that was registered. */ +static ScrDyn *scr_ee_entry_dyn(const ScrEeEntry *e) { + if (!e->flex_invoke) return NULL; + return scr_dyn_new_func(scr_closure_retain(e->flex_cb), e->flex_invoke, e->flex_arity, + e->flex_sig, e->flex_name); +} + +/* A meta event with a listener registered as a checked-dynamic function: + * the untyped argument vector — the affected event's name and, when it was + * registered dynamically, the listener (`fn`, borrowed, may be NULL) — + * beside the typed one-name tuple. False when no such listener exists (the + * caller emits the typed tuple alone). */ +static bool scr_ee_dyn_emit_meta(ScrEmitter *em, ScrStr *meta, ScrStr *name, const ScrDyn *fn) { + if (!scr_ee_bucket_has_flex(scr_ee_bucket_of(em->reg, meta))) return false; + ScrDyn *args[2] = { scr_dyn_new_str(name), (ScrDyn *)fn }; + scr_ee_emit_mixed_va(em, meta, args, fn ? 2 : 1, 1, name); + scr_dyn_release(args[0]); + return true; +} + +/* The function value of a listener about to be removed, for checked- + * dynamic 'removeListener' listeners (+1), or NULL. */ +static ScrDyn *scr_ee_dyn_removed(ScrEmitter *em, const ScrEeEntry *e) { + if (!scr_ee_bucket_has_flex(scr_ee_bucket_find(em->reg, "removeListener", 14))) return NULL; + return scr_ee_entry_dyn(e); +} + +/* The checked-dynamic half of the registry, installed by the first + * registration through an untyped value (scr_emitter_on_any): only such + * listeners take the meta events' untyped argument vector, so programs + * without untyped emitter calls never reach, or link, this half. */ +struct ScrEeDynOps { + bool (*emit_meta)(ScrEmitter *em, ScrStr *meta, ScrStr *name, const ScrDyn *fn); + ScrDyn *(*removed)(ScrEmitter *em, const struct ScrEeEntry *e); +}; +static const ScrEeDynOps scr_ee_dyn_ops = { &scr_ee_dyn_emit_meta, &scr_ee_dyn_removed }; +/* External linkage on purpose: the optimizer must not prove the hook's + * value from this unit's stores and inline the dynamic half back in. */ +SCR_TL const ScrEeDynOps *scr_ee_dyn = NULL; + +/* Fires a meta event with the affected event's NAME as the argument. + * Typed meta listeners are fenced to arity <= 1; listeners registered as + * checked-dynamic functions also receive the listener Node passes second + * when it was itself registered dynamically (`fn`, borrowed, may be NULL). */ +static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name, const ScrDyn *fn) { if (!scr_ee_has(em->reg, meta)) return; /* Node passes the symbol itself, which the string tuple cannot carry. */ if (scr_ee_is_error_monitor(name)) { @@ -374,7 +438,7 @@ static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name) { return; } ScrStr *m = scr_str_new(meta, strlen(meta)); - scr_ee_emit_va(em, m, name); + if (!scr_ee_dyn || !scr_ee_dyn->emit_meta(em, m, name, fn)) scr_ee_emit_va(em, m, name); scr_str_release(m); } @@ -435,19 +499,23 @@ static void scr_ee_warn_maybe(ScrEmitter *em, ScrEeBucket *b) { * try). */ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*moves*/, ScrClosure *orig /*moves; may be NULL*/, - ScrEeInvoke inv, ScrClosure *flex_cb /*moves; may be NULL*/, - ScrDynThunk flex_invoke, + ScrEeInvoke inv, const ScrDyn *dyn /*borrowed; may be NULL*/, bool once, bool prepend) { scr_ee_reg_ensure(em); - scr_ee_emit_meta(em, "newListener", name); + scr_ee_emit_meta(em, "newListener", name, dyn); ScrEeBucket *b = scr_ee_bucket_ensure(em->reg, name); ScrEeEntry *e = calloc(1, sizeof *e); if (!e) scr_ee_oom(); e->cb = cb; e->orig = orig; e->inv = inv; - e->flex_cb = flex_cb; - e->flex_invoke = flex_invoke; + if (dyn) { + e->flex_cb = scr_closure_retain(dyn->v.fn.clo); + e->flex_invoke = dyn->v.fn.thunk; + e->flex_sig = dyn->v.fn.sig; + e->flex_name = dyn->v.fn.name; + e->flex_arity = dyn->v.fn.arity; + } e->once = once; e->refs = 1; if (b->n == b->cap) { @@ -470,7 +538,7 @@ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*mov ScrEmitter *scr_emitter_on(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, cb, NULL, inv, NULL, NULL, once, prepend); + return scr_ee_add(em, name, cb, NULL, inv, NULL, once, prepend); } /* events.once(emitter, name): a pending promise of the event's arguments @@ -553,7 +621,7 @@ ScrPromise *scr_events_once(ScrEmitter *em, ScrStr *name, ScrClosure *boxer, voi * listener it announced is never added (the event's listener stays when * only the 'error' announcement throws). */ ScrCaught *thrown = NULL; - scr_emitter_release(scr_ee_add(em, name, st->event_cb, NULL, inv, NULL, NULL, true, false)); + scr_emitter_release(scr_ee_add(em, name, st->event_cb, NULL, inv, NULL, true, false)); if (scr_exc_pending()) { thrown = scr_exc_take(); scr_ee_unadd(em, name, st->event_cb); @@ -561,7 +629,7 @@ ScrPromise *scr_events_once(ScrEmitter *em, ScrStr *name, ScrClosure *boxer, voi st->event_cb = st->error_cb = NULL; } else if (st->error_cb != NULL) { ScrStr *error = scr_str_new("error", 5); - scr_emitter_release(scr_ee_add(em, error, st->error_cb, NULL, err_inv, NULL, NULL, true, false)); + scr_emitter_release(scr_ee_add(em, error, st->error_cb, NULL, err_inv, NULL, true, false)); if (scr_exc_pending()) { thrown = scr_exc_take(); scr_ee_unadd(em, error, st->error_cb); @@ -613,23 +681,87 @@ void scr_events_once_fire(ScrClosure *cb, ScrArr *args) { /* The dyn-adapted registration (JS-lane checked-dynamic listeners): the * ADAPTER closure (compiler-built — boxes the event tuple to dyn and - * calls the original through the checked-dynamic machinery) is what emit - * invokes; the ORIGINAL function (the dyn box's underlying closure) is - * the entry's identity. cb must be a SCR_DYN_FUNC (the emitted - * scr_emitter_check_listener ran first); adapter MOVES in, cb is - * borrowed. */ + * calls the original through the checked-dynamic machinery) is what a + * typed emit invokes; the ORIGINAL function (the dyn box's underlying + * closure) is the entry's identity, and its checked-dynamic thunk is what + * an untyped emit of the same name invokes, with every argument as given. + * cb must be a SCR_DYN_FUNC (the emitted scr_emitter_check_listener ran + * first); adapter MOVES in, cb is borrowed. */ ScrEmitter *scr_emitter_on_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, adapter, scr_closure_retain(cb->v.fn.clo), inv, NULL, NULL, once, - prepend); + return scr_ee_add(em, name, adapter, scr_closure_retain(cb->v.fn.clo), inv, + cb->kind == SCR_DYN_FUNC ? cb : NULL, once, prepend); } +/* The typed adapters of a checked-dynamic listener on the events whose + * tuples the runtime itself fixes: 'error' (one Error) and the meta + * events (one name string). caps[0] holds the dynamic function in a traced + * box, so a listener capturing its emitter stays collectable. */ +static void scr_ee_dyn_fire(ScrClosure *self, ScrDyn *arg /*moves*/) { + ScrDyn *fn = scr_box_get_ref(self->caps[0]); /* +1 */ + ScrDyn *args[1] = { arg }; + scr_dyn_release(scr_dyn_call(fn, args, 1, "listener")); + scr_dyn_release(arg); + scr_dyn_release(fn); +} + +static void scr_ee_dyn_error_fire(ScrClosure *self, ScrError *err) { + scr_ee_dyn_fire(self, scr_dyn_from_error(err)); +} + +static void scr_ee_dyn_name_fire(ScrClosure *self, ScrStr *name) { + scr_ee_dyn_fire(self, scr_dyn_new_str(name)); +} + +static ScrClosure *scr_ee_dyn_adapter(const ScrDyn *cb, void *fire) { + ScrClosure *clo = scr_closure_new(fire, 1); + ScrBox *box = scr_box_new_obj(&scr_dyn_retain_v, &scr_dyn_release_v, &scr_dyn_trace_v); + scr_box_set_ref(box, scr_dyn_retain((ScrDyn *)cb)); + clo->caps[0] = box; + return clo; +} + +static bool scr_ee_is_name(const ScrStr *name, const char *literal) { + size_t len = strlen(literal); + return name->len == len && memcmp(name->data, literal, len) == 0; +} + +/* A listener registered as a checked-dynamic function (computed event + * names, and runtime-internal registries such as the worker events, whose + * emits are untyped): its thunk alone. cb must be a function value (the + * caller ran the listener check). */ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, bool once, bool prepend) { - if (cb->kind != SCR_DYN_FUNC) scr_trap("scriptc: computed event listener is not a function\n"); - return scr_ee_add(em, name, NULL, NULL, NULL, scr_closure_retain(cb->v.fn.clo), - cb->v.fn.thunk, once, prepend); + if (cb->kind != SCR_DYN_FUNC) { + scr_dyn_check_listener(cb, "listener"); + return scr_emitter_retain(em); + } + return scr_ee_add(em, name, NULL, NULL, NULL, cb, once, prepend); +} + +/* on/once/prepend* through an untyped value, under a name the program never + * fixes a tuple for: the thunk, and under 'error' and the meta events, whose + * tuples typed code and the runtime emit with, a runtime-built typed adapter + * too. Installs the registry's dynamic half. */ +ScrEmitter *scr_emitter_on_any(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, + bool once, bool prepend) { + if (cb->kind != SCR_DYN_FUNC) { + scr_dyn_check_listener(cb, "listener"); + return scr_emitter_retain(em); + } + scr_ee_dyn = &scr_ee_dyn_ops; + void *fire = NULL; + if (!scr_ee_is_error_monitor(name)) { + if (scr_ee_is_name(name, "error")) fire = (void *)&scr_ee_dyn_error_fire; + else if (scr_ee_is_name(name, "newListener") || scr_ee_is_name(name, "removeListener")) + fire = (void *)&scr_ee_dyn_name_fire; + } + if (fire) { + return scr_ee_add(em, name, scr_ee_dyn_adapter(cb, fire), + scr_closure_retain(cb->v.fn.clo), &scr_ee_inv_fixed1, cb, once, prepend); + } + return scr_ee_add(em, name, NULL, NULL, NULL, cb, once, prepend); } /* The adapter-mediated registration (the LLVM backend): adapter is what @@ -638,7 +770,7 @@ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrEmitter *scr_emitter_on_via(ScrEmitter *em, ScrStr *name, ScrClosure *orig /*moves*/, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, adapter, orig, inv, NULL, NULL, once, prepend); + return scr_ee_add(em, name, adapter, orig, inv, NULL, once, prepend); } /* ── fixed-arity invoke shims (scr_runtime.h's contract) ────────────── @@ -702,8 +834,12 @@ static void scr_ee_remove_at_quiet(ScrEmitter *em, ScrEeBucket *b, size_t i) { static void scr_ee_remove_at(ScrEmitter *em, ScrEeBucket *b, size_t i) { ScrStr *name = scr_str_retain(b->name); + /* The removed listener as a function value, for checked-dynamic + * 'removeListener' listeners (Node's second argument). */ + ScrDyn *fn = scr_ee_dyn ? scr_ee_dyn->removed(em, b->ls[i]) : NULL; scr_ee_remove_at_quiet(em, b, i); - scr_ee_emit_meta(em, "removeListener", name); + scr_ee_emit_meta(em, "removeListener", name, fn); + if (fn) scr_dyn_release(fn); scr_str_release(name); } @@ -858,14 +994,25 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all) { /* ── emit ─────────────────────────────────────────────────────────────── */ -/* One dispatch pass over a SNAPSHOT of the bucket: entries invoke in list - * order through their emitted adapters, each with a fresh va_start over - * the emit site's typed tail. A once entry leaves the live list (firing - * 'removeListener', Node's wrapper calls removeListener) BEFORE its - * callback runs, and never runs twice even when a nested emit's snapshot - * still holds it (the shared fired flag — Node's wrapper.fired). A - * listener throw stops the pass (the exception stays pending for the - * caller). Returns whether the bucket had listeners. */ +/* The checked-dynamic argument vector of an untyped emit without + * arguments: flex_args is non-NULL exactly when the emit carries one. */ +static ScrDyn *const scr_ee_no_args[1] = { NULL }; + +/* A typed emit site passes its event's tuple as C variadics (ap_template); + * an untyped emit passes the checked-dynamic arguments as given + * (flex_args); an untyped emit of an event typed code also uses passes both + * (the compiler converted the arguments to the event's tuple for listeners + * with typed parameters). + * + * One dispatch pass over a SNAPSHOT of the bucket: entries invoke in list + * order, each listener registered as a checked-dynamic function through its + * thunk when the emit carries untyped arguments, otherwise through its + * emitted adapter with a fresh va_copy of the typed tail. A once entry + * leaves the live list (firing 'removeListener', Node's wrapper calls + * removeListener) BEFORE its callback runs, and never runs twice even when + * a nested emit's snapshot still holds it (the shared fired flag — Node's + * wrapper.fired). A listener throw stops the pass (the exception stays + * pending for the caller). Returns whether the bucket had listeners. */ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, ScrDyn *const *flex_args, size_t flex_argc); @@ -877,6 +1024,26 @@ static void scr_ee_emit_error_monitor(ScrEmitter *em, va_list *ap_template, scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc); } +/* A listener this emit has no argument form for: an untyped emit whose + * arguments have no conversion to a typed listener's parameters, or a typed + * emit reaching a dynamic listener without a typed adapter. Throws (the + * pass stops like a listener throw); never traps. */ +SCR_COLD static void scr_ee_listener_mismatch(const ScrStr *name, bool typed_emit) { + ScrJsonBuf text; + scr_jb_init(&text); + if (typed_emit) { + scr_jb_puts(&text, "emitting '"); + scr_jb_write(&text, name->data, name->len); + scr_jb_puts(&text, "' with typed arguments to a listener registered through an untyped value is not supported yet"); + } else { + scr_jb_puts(&text, "The '"); + scr_jb_write(&text, name->data, name->len); + scr_jb_puts(&text, "' listener has typed parameters the arguments of this untyped emit do not convert to"); + } + ScrStr *message = scr_jb_finish(&text); + scr_throw_error(typed_emit ? SCR_ERR_ERROR : SCR_ERR_TYPE, message); +} + static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, ScrDyn *const *flex_args, size_t flex_argc) { if (name->len == 5 && memcmp(name->data, "error", 5) == 0) { @@ -921,16 +1088,16 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, continue; } } - if (ap_template) { - if (!e->inv) scr_trap("scriptc: computed event collided with a fixed event\n"); + if (flex_args && e->flex_invoke) { + ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc); + scr_dyn_release(answer); /* EventEmitter ignores callback results. */ + } else if (ap_template && e->inv) { va_list ap; va_copy(ap, *ap_template); e->inv(e->cb, ap); va_end(ap); } else { - if (!e->flex_invoke) scr_trap("scriptc: computed event collided with a fixed event\n"); - ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc); - scr_dyn_release(answer); /* EventEmitter ignores callback results. */ + scr_ee_listener_mismatch(name, ap_template != NULL); } scr_ee_entry_unref(e); } @@ -946,6 +1113,17 @@ static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...) { return had; } +/* An untyped emit with the typed tuple too when `typed` (the variadics), + * or the untyped arguments alone. */ +static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, + int typed, ...) { + va_list ap; + va_start(ap, typed); + bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc); + va_end(ap); + return had; +} + /* emit(name, ...tuple) — the emitted call site passes the event's unified * tuple as typed C variadics (doubles, pointers, ints for bools); every * argument is BORROWED (adapters retain the +1 each callee owns). May @@ -958,11 +1136,126 @@ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) { return had; } -bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { - if (name->len == 5 && memcmp(name->data, "error", 5) == 0) { - scr_trap("scriptc: computed event collided with the error event\n"); +/* An untyped emit of an event typed code also emits or listens to: the + * arguments as given (borrowed) for listeners registered as checked-dynamic + * functions, and the compiler-converted tuple (the variadics, scr_emitter_emit's + * form) for typed listeners. */ +bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...) { + va_list ap; + va_start(ap, argc); + bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc); + va_end(ap); + return had; +} + +/* Whether an untyped emit of `name` reaches a listener without a checked- + * dynamic thunk (one registered with typed parameters), so the arguments + * must convert to the event's tuple first. Includes errorMonitor for + * 'error'. */ +bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name) { + ScrEeBucket *b = em->reg ? scr_ee_bucket_of(em->reg, name) : NULL; + for (size_t i = 0; b && i < b->n; i++) { + if (!b->ls[i]->flex_invoke) return true; } - return scr_ee_emit_core(em, name, NULL, args, argc); + if (!scr_ee_is_error_monitor(name) && scr_ee_is_name(name, "error")) + return scr_emitter_has_typed(em, SCR_EE_ERROR_MONITOR); + return false; +} + +/* Node's ERR_UNHANDLED_ERROR for a non-Error payload: the message quotes + * util.inspect of the payload. This unit renders the primitive payloads + * inspect prints plainly; others report that they are not supported. */ +static void scr_ee_unhandled_error(const ScrDyn *er) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Unhandled error. ("); + bool shown = true; + switch (er->kind) { + case SCR_DYN_UNDEF: scr_jb_puts(&text, "undefined"); break; + case SCR_DYN_NULL: scr_jb_puts(&text, "null"); break; + case SCR_DYN_BOOL: scr_jb_puts(&text, er->v.b ? "true" : "false"); break; + case SCR_DYN_NUM: { + char num[32]; + if (er->v.num == 0 && 1 / er->v.num < 0) { + scr_jb_puts(&text, "-0"); + } else { + size_t len = scr_f64_to_str(er->v.num, num); + scr_jb_write(&text, num, len); + } + break; + } + case SCR_DYN_STR: { + const ScrStr *s = er->v.str; + for (size_t i = 0; i < s->len && shown; i++) { + unsigned char c = (unsigned char)s->data[i]; + if (c < 0x20 || c == 0x7f || c == '\'' || c == '\\') shown = false; + } + if (s->len > 10000) shown = false; + if (shown) { + scr_jb_putc(&text, '\''); + scr_jb_write(&text, s->data, s->len); + scr_jb_putc(&text, '\''); + } + break; + } + default: shown = false; break; + } + ScrStr *message = scr_jb_finish(&text); + if (shown) { + ScrJsonBuf full; + scr_jb_init(&full); + scr_jb_write(&full, message->data, message->len); + scr_jb_putc(&full, ')'); + ScrStr *rendered = scr_jb_finish(&full); + scr_throw_error_msg_code(SCR_ERR_ERROR, rendered->data, rendered->len, "ERR_UNHANDLED_ERROR"); + scr_str_release(rendered); + } else { + const char *refusal = + "emit('error') through an untyped value with this non-Error payload and no 'error' listener is not supported yet"; + scr_throw_error(SCR_ERR_ERROR, scr_str_new(refusal, strlen(refusal))); + } + scr_str_release(message); +} + +/* emit('error', ...) through an untyped value: errorMonitor first; with no + * 'error' listener an Error payload throws itself and anything else throws + * ERR_UNHANDLED_ERROR, Node's contract; otherwise listeners receive the + * arguments as given, and typed listeners the payload as an Error. */ +static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + const ScrDyn *er = argc ? args[0] : scr_dyn_undefined(); + ScrError *err = scr_dyn_err_instanceof(er, SCR_ERR_ERROR) ? scr_errdyn_err_of(er) : NULL; + bool had = false; + if (!scr_ee_has(em->reg, "error")) { + scr_ee_emit_mixed_va(em, SCR_EE_ERROR_MONITOR, args, argc, err != NULL, err); + if (!scr_exc_pending()) { + if (err) scr_dyn_throw(scr_dyn_retain((ScrDyn *)er)); + else scr_ee_unhandled_error(er); + } + } else { + had = scr_ee_emit_mixed_va(em, name, args, argc, err != NULL, err); + } + scr_error_release(err); + return had; +} + +/* An untyped emit of a name typed code never fixes a tuple for (computed + * names, runtime-internal registries): the arguments as given. */ +bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); +} + +/* emit(name, ...args) through an untyped value: the arguments as given. + * 'error' keeps Node's unhandled-error contract, and the meta events reach + * typed listeners with a string name. */ +bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + if (!scr_ee_is_error_monitor(name)) { + if (scr_ee_is_name(name, "error")) return scr_ee_emit_error_dyn(em, name, args, argc); + if (scr_ee_is_name(name, "newListener") || scr_ee_is_name(name, "removeListener")) { + ScrStr *s = argc && args[0]->kind == SCR_DYN_STR ? args[0]->v.str : NULL; + return scr_ee_emit_mixed_va(em, name, args, argc, s != NULL, s); + } + } + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); } /* Handle event registries use checked callbacks for every tuple, including @@ -1073,6 +1366,26 @@ ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name) { return out; } +/* listeners(name) / rawListeners(name) through an untyped value: a +1 + * checked-dynamic array of the listener functions in list order. A + * listener registered with typed parameters has no checked-dynamic + * function value, so such a bucket reports that it is not supported. */ +ScrDyn *scr_emitter_listeners_dyn(ScrEmitter *em, ScrStr *name) { + ScrDyn *out = scr_dyn_new_arr(); + ScrEeBucket *b = em->reg ? scr_ee_bucket_of(em->reg, name) : NULL; + for (size_t i = 0; b && i < b->n; i++) { + ScrDyn *fn = scr_ee_entry_dyn(b->ls[i]); + if (!fn) { + scr_dyn_release(out); + const char *message = "listeners() through an untyped value of an event with a typed listener is not supported yet"; + scr_throw_error(SCR_ERR_ERROR, scr_str_new(message, strlen(message))); + return scr_dyn_new_arr(); + } + scr_dyn_arr_push(out, fn); + } + return out; +} + /* ── max listeners ────────────────────────────────────────────────────── */ /* The checked-dynamic setMaxListeners ladder: non-numbers throw Node's diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index 39a7f4b5b..b66de9e5a 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -95,12 +95,27 @@ void scr_emitter_check_listener(const ScrDyn *cb); ScrEmitter *scr_emitter_on_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend); -/* Computed-name listeners retain the original closure and its checked- - * dynamic call thunk, preserving the number of arguments passed to emit. - * The frontend keeps these names disjoint from fixed-tuple event names. */ +/* Listeners registered as checked-dynamic functions retain the original + * closure and its checked-dynamic call thunk, preserving the number of + * arguments passed to an untyped emit. on_dyn entries also carry the typed + * adapter for the event's fixed tuple; on_flex entries are for names the + * program emits only through untyped values or computed names. */ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, bool once, bool prepend); +/* Registration and emit through an untyped value (the class dispatch of + * compiled emitters): on_any adds runtime typed adapters under 'error' and + * the meta events and installs the dynamic half; emit_any keeps Node's + * unhandled-'error' contract and gives typed meta listeners a string. */ +ScrEmitter *scr_emitter_on_any(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, + bool once, bool prepend); +bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); ScrEmitter *scr_emitter_off_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb); +/* The registry's checked-dynamic half (meta-event argument vectors and + * rebuilt listener values), installed by the first on_any call; NULL in + * programs that never register a listener through an untyped value. + * Internal. */ +typedef struct ScrEeDynOps ScrEeDynOps; +extern SCR_TL const ScrEeDynOps *scr_ee_dyn; /* The adapter-mediated registration (the LLVM backend's listeners): the * ADAPTER closure is what emit invokes, the ORIGINAL closure is the * entry's identity (the on_dyn split, with a plain-closure identity). @@ -139,6 +154,12 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all); * emit('error', err): no listener ⇒ THROWS err (borrowed; +1 taken). */ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...); bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); +/* An untyped emit of an event with a fixed tuple: the arguments as given + * for dynamic listeners, plus the tuple converted by the caller as + * scr_emitter_emit's variadics for typed listeners. has_typed answers + * whether any listener of the name needs the converted tuple. */ +bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...); +bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name); bool scr_emitter_emit_error_flex(ScrEmitter *em, ScrDyn *error); bool scr_emitter_emit_error(ScrEmitter *em, ScrStr *name, ScrError *err); double scr_emitter_listener_count(ScrEmitter *em, ScrStr *name); @@ -154,6 +175,7 @@ ScrStr *scr_emitter_error_monitor(void); * other read). The stream constructors call this. */ void scr_emitter_reserve(ScrEmitter *em, const char *name); ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name); /* +1 closures */ +ScrDyn *scr_emitter_listeners_dyn(ScrEmitter *em, ScrStr *name); /* +1 dyn array */ ScrEmitter *scr_emitter_set_max(ScrEmitter *em, double n); /* returns em +1 */ double scr_emitter_get_max(ScrEmitter *em); void scr_emitter_set_default_max(double n); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 056d67c89..d18ce85e4 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1990,10 +1990,38 @@ static bool scr_dyn_is_extensible_view(const ScrDyn *view) { return (view->kind == SCR_DYN_OBJ || view->kind == SCR_DYN_ARR) && !view->non_extensible; } +/* A plain function's own properties (name, length, prototype, and the ones + * a program adds) live in its property table, which carries the function's + * integrity state too: freezing or sealing the function freezes or seals + * the table. Class constructors are not plain functions: their statics keep + * their own fences. */ +static bool scr_dyn_plain_fn(const ScrDyn *object) { + return object->kind == SCR_DYN_FUNC && !object->v.fn.class_obj; +} + +/* The table of a plain function for an integrity operation, created on + * first use. +1, or NULL for other values. */ +static ScrDyn *scr_dyn_fn_integrity_table(const ScrDyn *object) { + return scr_dyn_plain_fn(object) ? scr_dyn_fn_properties(object) : NULL; +} + +/* The table a plain function already has, for the integrity queries: one + * without a table has never been frozen, sealed or made non-extensible. + * +1, or NULL. */ +static ScrDyn *scr_dyn_fn_existing_table(const ScrDyn *function) { + ScrClosure *owner = scr_closure_identity(function->v.fn.clo); + return owner->props ? (ScrDyn *)scr_box_get_ref(owner->props) : NULL; +} + ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.preventExtensions"); return NULL; } if (scr_dyn_class_integrity_fence(object, "Object.preventExtensions")) return NULL; + ScrDyn *table = scr_dyn_fn_integrity_table(object); + if (table) { + table->non_extensible = true; + scr_dyn_release(table); + } if (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) { object->non_extensible = true; if (object->kind == SCR_DYN_ARR && object->v.arr.properties) @@ -2007,6 +2035,12 @@ bool scr_dyn_is_extensible(ScrDyn *object) { if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isExtensible"); return false; } bool answer; if (scr_dyn_typed_ref_view_answer(object, scr_dyn_is_extensible_view, &answer)) return answer; + if (scr_dyn_plain_fn(object)) { + ScrDyn *table = scr_dyn_fn_existing_table(object); + answer = !table || !table->non_extensible; + scr_dyn_release(table); + return answer; + } return scr_dyn_is_extensible_view(object); } @@ -2015,6 +2049,11 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) { if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.seal"); return NULL; } if (scr_dyn_class_integrity_fence(object, "Object.seal")) return NULL; if (object->symbol_properties) scr_dyn_release(scr_dyn_seal(object->symbol_properties)); + ScrDyn *table = scr_dyn_fn_integrity_table(object); + if (table) { + scr_dyn_release(scr_dyn_seal(table)); + scr_dyn_release(table); + } if (object->kind == SCR_DYN_OBJ) { object->non_extensible = true; for (size_t i = 0; i < object->v.obj.len; i++) object->v.obj.entries[i].configurable = false; @@ -2033,6 +2072,12 @@ bool scr_dyn_is_sealed(const ScrDyn *object) { bool answer; if (scr_dyn_typed_ref_view_answer((ScrDyn *)object, scr_dyn_is_sealed, &answer)) return answer; if (object->symbol_properties && !scr_dyn_is_sealed(object->symbol_properties)) return false; + if (scr_dyn_plain_fn(object)) { + ScrDyn *table = scr_dyn_fn_existing_table(object); + answer = table && scr_dyn_is_sealed(table); + scr_dyn_release(table); + return answer; + } if (object->kind == SCR_DYN_ARR) return object->non_extensible && (object->v.arr.len == 0 || object->v.arr.sealed) && (!object->v.arr.properties || scr_dyn_is_sealed(object->v.arr.properties)); @@ -7492,8 +7537,13 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { /* Object.prototype.toString with the Promise @@toStringTag. */ return scr_str_new("[object Promise]", 16); case SCR_DYN_ARR: { + /* Array.prototype.join reads the length once and each element by index: + * an element's toString can append to or shrink the array, so an index + * past the current length reads undefined, and the element stays + * retained while it converts. */ + size_t len = d->v.arr.len; ScrStr *out = scr_str_new("", 0); - for (size_t i = 0; i < d->v.arr.len; i++) { + for (size_t i = 0; i < len; i++) { if (i > 0) { ScrStr *comma = scr_str_new(",", 1); ScrStr *joined = scr_str_concat(out, comma); @@ -7501,9 +7551,25 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { scr_str_release(comma); out = joined; } - const ScrDyn *e = d->v.arr.items[i]; - if (e->kind == SCR_DYN_UNDEF || e->kind == SCR_DYN_NULL) continue; /* JS join: empty */ - ScrStr *piece = scr_dyn_string_coerce(e); + ScrDyn *e = scr_dyn_arr_at(d, (double)i); + if (!e) { /* an inherited getter threw: the exception is pending */ + scr_str_release(out); + return NULL; + } + if (e->kind == SCR_DYN_UNDEF || e->kind == SCR_DYN_NULL) { /* JS join: empty */ + scr_dyn_release(e); + if (scr_exc_pending()) { + scr_str_release(out); + return NULL; + } + continue; + } + /* ToString with the object protocol: an element's own or inherited + * toString/valueOf runs, as in Array.prototype.join. */ + ScrStr *piece = NULL; + if (e->kind == SCR_DYN_PROXY) scr_dyn_proxy_unsupported("string conversion"); + else piece = scr_dyn_string_coerce_js(e); + scr_dyn_release(e); if (!piece || scr_exc_pending()) { scr_str_release(piece); scr_str_release(out); @@ -14382,12 +14448,23 @@ ScrStr *scr_dyn_to_string_method(const ScrDyn *d, const ScrStr *enc, const ScrSt ScrDyn *scr_dyn_freeze(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value)) return NULL; if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; - if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze(value->symbol_properties)); + ScrDyn *table = scr_dyn_fn_integrity_table(value); + if (!table) return scr_dyn_freeze_data(value); + if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze_data(value->symbol_properties)); + scr_dyn_release(scr_dyn_freeze_data(table)); + scr_dyn_release(table); + return scr_dyn_retain(value); +} + +ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { + if (scr_dyn_class_reflection_fence(value)) return NULL; + if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; + if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze_data(value->symbol_properties)); if (value->kind == SCR_DYN_ARR) { value->non_extensible = true; value->v.arr.sealed = true; value->v.arr.frozen = true; - if (value->v.arr.properties) scr_dyn_release(scr_dyn_freeze(value->v.arr.properties)); + if (value->v.arr.properties) scr_dyn_release(scr_dyn_freeze_data(value->v.arr.properties)); return scr_dyn_retain(value); } if (value->kind == SCR_DYN_OBJ && !value->copied_from_native) { @@ -14412,6 +14489,12 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { bool answer; if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; + if (scr_dyn_plain_fn(value)) { + ScrDyn *table = scr_dyn_fn_existing_table(value); + answer = table && scr_dyn_is_frozen(table); + scr_dyn_release(table); + return answer; + } if (value->kind == SCR_DYN_ARR) return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) && (!value->v.arr.properties || scr_dyn_is_frozen(value->v.arr.properties)); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 976847049..db0926690 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4596,6 +4596,13 @@ void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath) { static int scr_rm_unlink(const char *path, size_t len); +/* Node's ERR_FS_EISDIR for rm on a directory without `recursive`. */ +static void scr_fs_rm_throw_eisdir(const ScrStr *path) { + ScrStr *message = scr_str_new("is a directory", 14); + scr_fs_throw_system_error("ERR_FS_EISDIR", "Path is a directory", EISDIR, "rm", message, path); + scr_str_release(message); +} + void scr_fs_rm(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return; #ifdef _WIN32 @@ -4603,16 +4610,15 @@ void scr_fs_rm(ScrStr *path) { if (link != 0) { if (link < 0) scr_fs_throw(errno, "unlink", path); return; } #endif /* Node's rmSync: lstat first (a missing path reports the lstat syscall), - * refuse directories (Node requires `recursive`, which the scriptc - * surface doesn't declare — the message wording diverges from Node's - * ERR_FS_EISDIR, see SEMANTICS.md), then unlink. */ + * refuse directories (ERR_FS_EISDIR: removing one requires `recursive`), + * then unlink. */ struct stat st; if (lstat(path->data, &st) != 0) { scr_fs_throw(errno, "lstat", path); return; } if (S_ISDIR(st.st_mode)) { - scr_fs_throw(EISDIR, "rm", path); + scr_fs_rm_throw_eisdir(path); return; } if (scr_rm_unlink(path->data, path->len) != 0) scr_fs_throw(errno, "unlink", path); @@ -4766,6 +4772,13 @@ typedef struct { ScrStr *path; } ScrRmFail; +/* Throws the recorded failure and releases its path. */ +static void scr_rm_fail_throw(ScrRmFail *f) { + if (f->err == EISDIR && strcmp(f->op, "rm") == 0) scr_fs_rm_throw_eisdir(f->path); + else scr_fs_throw(f->err, f->op, f->path); + scr_str_release(f->path); +} + static void scr_rm_fail_set(ScrRmFail *f, int err, const char *op, const char *path, size_t len) { if (f->err != 0) return; /* first failure wins, exactly like the old unwind */ f->err = err; @@ -4861,8 +4874,6 @@ static void scr_fs_rm_attempt(ScrStr *path, bool recursive, bool force, ScrRmFai } if (S_ISDIR(st.st_mode)) { if (!recursive) { - /* Node throws ERR_FS_EISDIR here; the EISDIR-prefixed wording is - * divergence 13's documented difference. */ scr_rm_fail_set(f, EISDIR, "rm", path->data, path->len); return; } @@ -4876,18 +4887,24 @@ void scr_fs_rm_opts(ScrStr *path, bool recursive, bool force) { if (!scr_fs_path_ok(path, "path")) return; ScrRmFail f = {0, NULL, NULL}; scr_fs_rm_attempt(path, recursive, force, &f); - if (f.err != 0) { - scr_fs_throw(f.err, f.op, f.path); - scr_str_release(f.path); - } + if (f.err != 0) scr_rm_fail_throw(&f); +} + +/* Node's validateRmdirOptions range checks: retryDelay is an int32 >= 0, + * then maxRetries is a uint32 (ERR_OUT_OF_RANGE). */ +bool scr_fs_rm_retry_options_ok(double max_retries, double retry_delay) { + return scr_bytes_validate_off("options.retryDelay", retry_delay, 2147483647.0) && + scr_bytes_validate_off("options.maxRetries", max_retries, 4294967295.0); } /* rmSync(p, { recursive, force, maxRetries, retryDelay }): Node retries * the operation on EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM up to maxRetries * times, waiting retryDelay ms LONGER on each try (linear backoff — the * fs.rmSync documented semantics). Everything else throws immediately - * with the failing path, exactly like the plain options form. */ + * with the failing path, exactly like the plain options form. rmSync + * validates the options before the path. */ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, double retry_delay) { + if (!scr_fs_rm_retry_options_ok(max_retries, retry_delay)) return; if (!scr_fs_path_ok(path, "path")) return; long tries = max_retries > 0 ? (long)max_retries : 0; double delay_ms = retry_delay > 0 ? retry_delay : 0; @@ -4912,8 +4929,7 @@ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_r nanosleep(&ts, NULL); } } - scr_fs_throw(f.err, f.op, f.path); - scr_str_release(f.path); + scr_rm_fail_throw(&f); } #if defined(_WIN32) || defined(__wasi__) diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index bae678292..1b859cea1 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -306,6 +306,7 @@ void scr_fs_rm_opts(ScrStr *path, bool recursive, bool force); * EPERM failures up to maxRetries times with Node's linear backoff * (retryDelay ms longer per try); everything else throws immediately. */ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, double retry_delay); +bool scr_fs_rm_retry_options_ok(double max_retries, double retry_delay); ScrStr *scr_fs_mkdtemp(ScrStr *prefix); ScrStr *scr_fs_mkdtemp_async(ScrStr *prefix); /* the callback and promise error path */ void scr_fs_access(ScrStr *path, double mode); @@ -493,6 +494,9 @@ ScrPromise *scr_fsp_utimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn ScrPromise *scr_fsp_lutimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn *mtime); ScrPromise *scr_fsp_readdir(ScrStr *path); ScrPromise *scr_fsp_rm(ScrStr *path); +ScrPromise *scr_fsp_rm_opts(ScrStr *path, bool recursive, bool force); +ScrPromise *scr_fsp_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, + double retry_delay); ScrPromise *scr_fsp_stat(ScrStr *path); ScrPromise *scr_fsp_realpath(ScrStr *path); ScrPromise *scr_fsp_lstat(ScrStr *path); diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index ecd1a558b..5d4935ed0 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -702,11 +702,18 @@ static void scr_map_compact(ScrMap *m, bool reindex) { * range. */ #define SCR_MAP_MAX_ENTRIES ((size_t)1 << 30) -/* The first entry gets an array of one, so a one-entry map keeps no unused - * slots; the second grows it to four, and capacity doubles after that. */ +/* The entry capacity after `cap`: 1, 2, 3, 4, 6, 8, 12, 16, then doubling. + * Most maps hold one to four entries, so a small map keeps at most a third + * of its slots unused, and appends stay amortized constant. */ +static size_t scr_map_next_ecap(size_t cap) { + if (cap < 4) return cap + 1; + if (cap < 16) return cap < 8 ? cap + 2 : cap + 4; + return cap * 2; +} + static void scr_map_grow_entries(ScrMap *m) { - size_t cap = m->ecap == 0 ? 1 : m->ecap < 4 ? 4 : m->ecap; - while (cap < m->nentries + 1) cap *= 2; + size_t cap = scr_map_next_ecap(m->ecap); + while (cap < m->nentries + 1) cap = scr_map_next_ecap(cap); if (cap > SCR_MAP_MAX_ENTRIES) scr_map_oom(); ScrMapEntry *entries = scr_mem_realloc(m->entries, cap * scr_map_entry_size(m)); if (!entries) scr_map_oom(); diff --git a/packages/runtime/src/scr_map.test.c b/packages/runtime/src/scr_map.test.c index d6f1163f4..9916c7406 100644 --- a/packages/runtime/src/scr_map.test.c +++ b/packages/runtime/src/scr_map.test.c @@ -242,17 +242,17 @@ static void modes(void) { scr_map_release(set); } -/* Small maps keep compact storage: the entries array starts with one slot - * and grows to four, and maps of up to 8 entries keep no bucket table. */ +/* Small maps keep compact storage: the entries array grows through 1, 2, + * 3, 4, 6 and 8 slots, and maps of up to 8 entries keep no bucket table. */ static void small_storage(void) { ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL); assert(!map->entries && map->ecap == 0); - scr_map_set_f64_f64(map, 100000, 1); - assert(map->ecap == 1); - scr_map_set_f64_f64(map, 200000, 2); - assert(map->ecap == 4); - for (int i = 3; i <= 8; i++) scr_map_set_f64_f64(map, i * 100000.0, i); - assert(map->ecap == 8 && !map->nbuckets && !map->ndense); + static const uint32_t caps[] = {1, 2, 3, 4, 6, 6, 8, 8}; + for (int i = 1; i <= 8; i++) { + scr_map_set_f64_f64(map, i * 100000.0, i); + assert(map->ecap == caps[i - 1]); + } + assert(!map->nbuckets && !map->ndense); double out; for (int i = 1; i <= 8; i++) assert(scr_map_get_f64_f64(map, i * 100000.0, &out) && out == i); assert(!scr_map_has_f64(map, 50000) && !scr_map_has_f64(map, 0)); diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 750dcc97d..cd0e92740 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -282,7 +282,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) /* Published values (immortal) travel by pointer: a typed value as a * capsule over the same object, a dyn graph as itself. */ if (input->kind == SCR_DYN_TYPED_REF && input->v.typed_ref.ptr && - SCR_RC_COUNT(input->v.typed_ref.ptr) == SCR_RC_IMMORTAL) { + SCR_RC_PUBLISHED(input->v.typed_ref.ptr)) { scr_sa_thread_shares(); encoder->visits[id].view = (ScrDyn *)input; ScrMessageNode *node = &encoder->message->nodes[id]; diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 765b59a05..ab7da1ebd 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -4329,7 +4329,7 @@ ScrDyn *scr_net_block_list_private_ranges(void) { "::1/128", "169.254.0.0/16", "fe80::/10", "fc00::/7" }; ScrDyn *arr = scr_dyn_new_arr(); for (size_t i = 0; i < sizeof cidrs / sizeof cidrs[0]; i++) scr_dyn_arr_push(arr, scr_net_dyn_str(cidrs[i])); - ranges = scr_dyn_freeze(arr); /* +1 on the same array */ + ranges = scr_dyn_freeze_data(arr); /* +1 on the same array */ scr_dyn_release(arr); scr_net_private_ranges = ranges; scr_atexit(scr_net_private_ranges_cleanup); diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index 500283995..8fa68738d 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -1327,7 +1327,7 @@ ScrDyn *scr_os_constants(void) { scr_os_constant(os, "UV_UDP_REUSEADDR", 4); scr_dyn_obj_set(os, "dlopen", 6, dlopen); scr_dyn_obj_set(os, "errno", 5, errnos); - scr_dyn_release(scr_dyn_freeze(signals)); + scr_dyn_release(scr_dyn_freeze_data(signals)); scr_dyn_obj_set(os, "signals", 7, signals); scr_dyn_obj_set(os, "priority", 8, priority); scr_os_constants_value = os; @@ -1521,7 +1521,7 @@ ScrDyn *scr_node_constants(const ScrDyn *crypto_table) { } } scr_dyn_release(crypto); - scr_dyn_release(scr_dyn_freeze(o)); + scr_dyn_release(scr_dyn_freeze_data(o)); scr_node_constants_value = o; scr_atexit(scr_node_constants_clear); return scr_dyn_retain(o); diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 0cbb02c8f..025e13a51 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -42,7 +42,8 @@ typedef struct { } ScrPubLog; /* In ScrPubLog.buf: the object keeps its class id in the high half of - * its rc word (SCR_PUB_CLASS_ID), and `rc` is the count of the low half. */ + * its rc word (SCR_PUB_CLASS_ID), and `rc` is the count of the low half, + * or it is compact (SCR_PUB_COMPACT), and `rc` is its header word. */ #define SCR_PUB_LOG_COUNT UINT32_C(0x80000000) #define SCR_PUB_DYN ((uintptr_t)1) @@ -108,7 +109,7 @@ static void *scr_pub_grow(void *p, size_t *cap, size_t size, void *inline_buf) { void scr_pub_str_fn(void *v, ScrPub *ctx); void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn) { - if (!v || SCR_RC_COUNT(v) == SCR_RC_IMMORTAL || ctx->refusal) return; + if (!v || SCR_RC_PUBLISHED(v) || ctx->refusal) return; if (fn == scr_pub_str_fn) { /* a leaf: mark it now */ scr_pub_str_fn(v, ctx); return; @@ -125,9 +126,11 @@ void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn) { * touch the objects again. */ bool scr_pub_mark(ScrPub *ctx, void *obj, bool cyc, int kind) { size_t *rc = obj; - if (SCR_RC_COUNT(obj) == SCR_RC_IMMORTAL || ctx->refusal) return false; - bool class_id = (kind & SCR_PUB_CLASS_ID) != 0; - kind &= ~SCR_PUB_CLASS_ID; + if (SCR_RC_PUBLISHED(obj) || ctx->refusal) return false; + bool compact = (kind & SCR_PUB_COMPACT) != 0; + /* Both log the low 32 bits: a count, or a compact header word. */ + bool class_id = compact || (kind & SCR_PUB_CLASS_ID) != 0; + kind &= ~(SCR_PUB_CLASS_ID | SCR_PUB_COMPACT); if (ctx->logged == ctx->log_cap) ctx->log = scr_pub_grow(ctx->log, &ctx->log_cap, sizeof *ctx->log, ctx->log_inline); #ifdef SCR_RC_AUDIT @@ -156,7 +159,8 @@ bool scr_pub_mark(ScrPub *ctx, void *obj, bool cyc, int kind) { } #endif *e = (ScrPubLog){(uintptr_t)obj, logged, buf}; - if (class_id) SCR_RC_COUNT(obj) = SCR_RC_IMMORTAL; + if (compact) SCR_RC_COUNT(obj) |= SCR_RC_COMPACT_PUBLISHED; + else if (class_id) SCR_RC_COUNT(obj) = SCR_RC_IMMORTAL; else *rc = SIZE_MAX; return true; } @@ -382,7 +386,7 @@ static bool scr_pub_keep_ok(size_t bytes) { } void scr_publish(void *root, ScrPubFn fn) { - if (!root || SCR_RC_COUNT(root) == SCR_RC_IMMORTAL) return; + if (!root || SCR_RC_PUBLISHED(root)) return; ScrPub ctx; if (scr_pub_keep.stack) { ctx.stack = scr_pub_keep.stack; @@ -460,7 +464,7 @@ void scr_publish(void *root, ScrPubFn fn) { /* Other threads share the object; its shape belongs to this one * (scr_js.h). */ if (d->kind == SCR_DYN_OBJ) d->v.obj.shape = NULL; - scr_dyn_release(scr_dyn_freeze(d)); + scr_dyn_release(scr_dyn_freeze_data(d)); } #ifdef SCR_RC_AUDIT scr_pub_audit_forget(ctx.kinds[i]); diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 889202975..9cb88e6e8 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -27,6 +27,24 @@ typedef size_t ScrRcCount; #define SCR_RC_IMMORTAL ((ScrRcCount)-1) #define SCR_RC_COUNT(obj) (*(ScrRcCount *)(obj)) +/* Compact class instances (64-bit targets; llvm/classes.ts + * LlClassMeta.compact): the instances of a ProgramLifetime hierarchy count + * nothing, so their header is one 32-bit word instead of the rc word, and + * their fields start at offset 4. The word is SCR_RC_COMPACT_TAG, the class + * id (0 outside a hierarchy) in the low 28 bits, and SCR_RC_COMPACT_PUBLISHED + * once publication marks it. No count reaches the tag, so a word read through + * SCR_RC_COUNT tells the two apart, and a count or word of + * SCR_RC_PUBLISHED_MIN and up is published for every object. Only emitted + * code holds compact instances in typed slots; the runtime reaches them + * through their emitted adapters, publication walkers (SCR_PUB_COMPACT) and + * the published test. */ +#define SCR_RC_COMPACT_TAG UINT32_C(0x70000000) +#define SCR_RC_COMPACT_PUBLISHED UINT32_C(0x80000000) +#define SCR_RC_COMPACT_ID_MASK UINT32_C(0x0fffffff) +#define SCR_RC_PUBLISHED_MIN ((ScrRcCount)UINT32_C(0xf0000000)) +/* Whether `obj`, an object of any kind, is published (immortal). */ +#define SCR_RC_PUBLISHED(obj) (SCR_RC_COUNT(obj) >= SCR_RC_PUBLISHED_MIN) + /* The class id in the high half of `obj`'s rc word, or 0 for an object * without one: the high half of any other object's word is 0, or 0xffffffff * (immortal) or 0x80000000 (program-lifetime), and a class id is never one of @@ -273,6 +291,10 @@ void scr_rt_free(void *p); * of [lo, hi), aligned to 256 KiB, in ahead of use; builds without the * helper ignore it. */ void scr_mem_prefault(void *lo, void *hi); +/* Returns the pages of [lo, hi) (page aligned), which hold no data, to the + * system (Linux): pre-fault requests for them are dropped first, so they + * stay returned. Other builds ignore it. */ +void scr_mem_release_ahead(void *lo, void *hi); /* Reorders ptrs, distinct pointers `offset` bytes into blocks that are * about to be freed in array order, so that the free lists hand the blocks * back in ascending address order (scr_alloc.c). Builds without the @@ -601,6 +623,7 @@ static inline void scr_cyc_dead(void *obj) { * after and wants nothing reclaimable left), and it costs a walk of the * live heap — do not put it on a per-turn path. */ void scr_collect_cycles(void); +void scr_collect_cycles_phase(void); void scr_cyc_context_cleanup(void); /* One pass on the normal generational schedule, for callers that reach a @@ -636,6 +659,10 @@ enum { * the high half (SCR_RC_COUNT). The mark, and a refusal's restore, write * only the count. */ SCR_PUB_CLASS_ID = 0x100, + /* Or'ed into a kind: a compact class instance (SCR_RC_COMPACT_TAG). The + * mark sets SCR_RC_COMPACT_PUBLISHED; a refusal's restore writes the + * logged word back. */ + SCR_PUB_COMPACT = 0x200, }; void scr_publish(void *root, ScrPubFn fn); void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn); diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index c9d6c5511..3a7dad541 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -1362,6 +1362,18 @@ static void scr_rgn_thread_exit(void) { ScrRegion *r = i < t->nregions ? t->regions[i] : all[0]; if (!r) continue; scr_rgn_spare_drop(r); +#if defined(SCR_WORKERS) && SCR_RGN_MMAP && !defined(SCR_RGN_POISON) + /* Nothing is carved here after a script thread exits: the pages faulted + * in ahead of each chunk's bump go back while the program goes on, and + * its objects stay. */ + if (!scr_context_is_main()) + for (ScrRgnChunk *c = r->chunks; c; c = c->next) { + /* 64 KiB steps cover every page size, as in scr_rgn_chunk_retire. */ + char *from = (char *)(((uintptr_t)c->bump + 0xffff) & ~(uintptr_t)0xffff); + if (c->touched > from) scr_mem_release_ahead(from, c->touched); + c->touched = c->bump; + } +#endif for (ScrRgnSeg *s = r->segs, *next; s; s = next) { next = s->next; free(s); diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index ddd984e72..41588f25a 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1759,10 +1759,14 @@ static __attribute__((noinline)) size_t scr_byte_to_u16_walk(const ScrStr *s, Sc } /* ECMA-262 ToIntegerOrInfinity for a double already known to be a Number: - * NaN → +0, otherwise truncate toward zero, ±Infinity preserved. */ -static double scr_to_integer_or_infinity(double x) { - if (isnan(x)) return 0.0; - return trunc(x); + * NaN → +0, otherwise truncate toward zero, ±Infinity preserved. Inside + * (-2^63, 2^63) the integer conversion truncates, without the libm trunc + * call baseline x86-64 needs; a fraction above -1 gives +0, as the spec's + * mathematical result does. Every double outside that range is already an + * integer or infinite, and NaN fails both comparisons. */ +static inline double scr_to_integer_or_infinity(double x) { + if (x > -9223372036854775808.0 && x < 9223372036854775808.0) return (double)(int64_t)x; + return isnan(x) ? 0.0 : x; } /* Byte substring search (needle and hay are both canonical WTF-8, diff --git a/packages/runtime/src/scr_wasm.c b/packages/runtime/src/scr_wasm.c index 121acacbb..384bb5e77 100644 --- a/packages/runtime/src/scr_wasm.c +++ b/packages/runtime/src/scr_wasm.c @@ -5,7 +5,9 @@ * WebAssembly.compile, instantiate, validate and new Module hash the bytes * they are given and look the module up by SHA-256 and length in * scr_wasm_registry, which the compiler emits; bytes that were not - * translated at build time reject with a CompileError that says so. + * translated at build time reject with a CompileError that says so, and + * that names the reason for modules the build saw but does not translate + * (scr_wasm_untranslated). * Nothing is interpreted or compiled at run time, and a translated * module's bytes were validated when the program was built. * @@ -1668,12 +1670,35 @@ static const ScrWasmModuleDesc *scr_wasm_lookup(const uint8_t *data, size_t len) return NULL; } -static void scr_wasm_not_translated(const char *what) { - char msg[400]; - snprintf(msg, sizeof msg, - "%s: this module was not translated when the program was built (scriptc compiles WebAssembly ahead of " - "time and cannot compile modules whose bytes it did not see; see https://scriptc.dev/limitations)", - what); +/* The CompileError for bytes the registry does not hold: the reason the + * build recorded for a module it saw but does not translate, or the + * message for bytes it did not see. */ +static void scr_wasm_not_translated(const char *what, const uint8_t *data, size_t len) { + const ScrWasmUntranslatedDesc *found = NULL; + uint8_t digest[32]; + bool hashed = false; + for (uint64_t i = 0; i < scr_wasm_untranslated.count && !found; i++) { + const ScrWasmUntranslatedDesc *d = &scr_wasm_untranslated.modules[i]; + if (d->byte_length != len) continue; + if (!hashed) { + scr_wasm_sha256(data, len, digest); + hashed = true; + } + if (!memcmp(d->sha256, digest, 32)) found = d; + } + char msg[800]; + if (found) { + int reason_len = found->reason_len > 600 ? 600 : (int)found->reason_len; + snprintf(msg, sizeof msg, + "%s: this module was found when the program was built but not translated (%.*s; see " + "https://scriptc.dev/limitations)", + what, reason_len, found->reason); + } else { + snprintf(msg, sizeof msg, + "%s: this module was not translated when the program was built (scriptc compiles WebAssembly ahead of " + "time and cannot compile modules whose bytes it did not see; see https://scriptc.dev/limitations)", + what); + } scr_wasm_throw_kind(SCR_WASM_COMPILE_ERROR, msg); } @@ -1683,7 +1708,7 @@ static ScrDyn *scr_wasm_module_from(ScrDyn *bytes, const char *what, const char if (!scr_wasm_bytes_of(bytes, what, expected, &data, &len)) return NULL; const ScrWasmModuleDesc *desc = scr_wasm_lookup(data, len); if (!desc) { - scr_wasm_not_translated(what); + scr_wasm_not_translated(what, data, len); return NULL; } ScrWasmObj *o = scr_wasm_obj_new(SCR_WASM_K_MODULE); @@ -2043,7 +2068,7 @@ static ScrDyn *scr_wasm_instance_from(const ScrWasmModuleDesc *desc, ScrDyn *imp } scr_dyn_obj_set(exports, ex->name, (size_t)ex->name_len, value); } - ScrDyn *frozen = scr_dyn_freeze(exports); + ScrDyn *frozen = scr_dyn_freeze_data(exports); scr_dyn_release(frozen); host->exports = exports; ScrWasmObj *o = scr_wasm_obj_new(SCR_WASM_K_INSTANCE); diff --git a/packages/runtime/src/scr_wasm_abi.h b/packages/runtime/src/scr_wasm_abi.h index 083a2f77a..d90deabc3 100644 --- a/packages/runtime/src/scr_wasm_abi.h +++ b/packages/runtime/src/scr_wasm_abi.h @@ -174,6 +174,22 @@ typedef struct { } ScrWasmRegistry; extern const ScrWasmRegistry scr_wasm_registry; +/* The modules the build saw but does not translate, emitted next to the + * registry: unsupported proposals, invalid modules and translator + * limits. `reason` is a clause naming why (not NUL-terminated). */ +typedef struct { + uint8_t sha256[32]; + uint64_t byte_length; + const char *reason; + uint64_t reason_len; +} ScrWasmUntranslatedDesc; + +typedef struct { + uint64_t count; + const ScrWasmUntranslatedDesc *modules; +} ScrWasmUntranslatedTable; +extern const ScrWasmUntranslatedTable scr_wasm_untranslated; + /* Runtime objects the translated code reads at fixed offsets. */ typedef struct ScrWasmMemory { uint8_t *base; /* 0 */ diff --git a/packages/runtime/test/lib.test.ts b/packages/runtime/test/lib.test.ts index fbecaff37..2a32202b7 100644 --- a/packages/runtime/test/lib.test.ts +++ b/packages/runtime/test/lib.test.ts @@ -62,7 +62,8 @@ afterAll(async () => { // fs failure through scr_exc_print_uncaught. fs failures are Error // INSTANCES whose messages carry Node's exact shape (": , // ''"), so the uncaught line renders "Error: " — -// the same first line Node prints for an uncaught fs error. Typed catches +// the same first line Node prints for an uncaught fs error (rm on a +// directory is Node's ERR_FS_EISDIR SystemError instead). Typed catches // observe the message itself (`e.message`, corpus-covered differentially); // these lines pin OUR uncaught rendering. test("process + fs library: checks pass, error messages match Node's shape", async () => { @@ -76,7 +77,7 @@ test("process + fs library: checks pass, error messages match Node's shape", asy `Uncaught Error: ENOENT: no such file or directory, scandir '${scratch}/missing.txt'`, `Uncaught Error: EEXIST: file already exists, mkdir '${scratch}/sub'`, `Uncaught Error: ENOENT: no such file or directory, mkdir '${scratch}/nope/deep'`, - `Uncaught Error: EISDIR: illegal operation on a directory, rm '${scratch}/sub'`, + `Uncaught SystemError [ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) ${scratch}/sub`, ``, ].join("\n"), ); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index d4deec3c3..723e9519f 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -536,6 +536,29 @@ static void check_mixed_dead_structure_narrows(void) { check(live <= narrowed, "a pass that freed a dead mature and old structure did not narrow the growth fraction"); } +/* The phase sweep (scr_collect_cycles_phase) is for a caller that knows a + * phase's data has just died: it sweeps, and the growth schedule starts + * over. After passes that find nothing have widened the old level's + * fraction to the cap, the plain sweep leaves the next dead cycle to the + * cap, while after the phase sweep it goes once the heap has grown the base + * quarter past the floor. */ +static void check_phase_sweep_restarts_schedule(void) { + enum { FLOOR = 4096 }; + size_t cap = configured_growth_cap(); + if (cap < 2) return; /* the base fraction is already the cap */ + size_t slack = 2 * configured_nursery_threshold() + 2; + size_t due = FLOOR + FLOOR * cap; + size_t live = 0; + for (int i = 0; i < 4; i++) live = grow_dead_cycle_until_full_pass(0, false); + check(live > due, "passes that found nothing did not widen the growth fraction to the cap"); + scr_collect_cycles(); + live = grow_dead_cycle_until_full_pass(0, false); + check(live > due, "the plain sweep changed the growth schedule"); + scr_collect_cycles_phase(); + live = grow_dead_cycle_until_full_pass(0, false); + check(live <= FLOOR + FLOOR / 4 + slack, "the phase sweep did not restart the growth schedule"); +} + static void check_deep_ring(void) { enum { DEPTH = 100000 }; size_t before = freed; @@ -1433,6 +1456,7 @@ int main(void) { check_growth_cap_bounds_floating_garbage(); check_mixed_dead_structure_narrows(); check_death_waits_for_idle(); + check_phase_sweep_restarts_schedule(); check_death_brings_full_pass(); check_nursery_bypass(); check_mature_bypass(); diff --git a/scripts/generate-ts7-ast-schema.mjs b/scripts/generate-ts7-ast-schema.mjs index 30c506869..a1bc80117 100644 --- a/scripts/generate-ts7-ast-schema.mjs +++ b/scripts/generate-ts7-ast-schema.mjs @@ -268,10 +268,18 @@ if (process.argv.includes("--check")) { // declarations, and require every wire property to have a declared getter. const declarations = readFileSync(join(packageRoot, "dist/api/node/node.generated.d.ts"), "utf8"); const names = [...new Set(Object.values(protocol.childProperties).flat())].sort(); -// These views adapt TS7's shared postfix slot and reparsed JSDoc bodies, and -// return modifiers as a modifier list. Their implementations live outside the -// generated region in AstNode. -const adaptedGetters = new Set(["body", "modifiers", "questionToken"]); +// These views adapt TS7's shared postfix slot and reparsed JSDoc bodies, +// return modifiers as a modifier list, and return write targets without their +// wrappers. Their implementations live outside the generated region in +// AstNode. +const adaptedGetters = new Set([ + "body", + "initializer", + "left", + "modifiers", + "operand", + "questionToken", +]); const getters = names .filter((name) => !adaptedGetters.has(name)) .map((name) => { diff --git a/tests/corpus/4635-contract-program-lifetime-layouts.ts b/tests/corpus/4635-contract-program-lifetime-layouts.ts new file mode 100644 index 000000000..7b300091e --- /dev/null +++ b/tests/corpus/4635-contract-program-lifetime-layouts.ts @@ -0,0 +1,285 @@ +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +// ProgramLifetime classes whose fields mix widths: booleans, small integers, +// numbers, strings, references and optional values, declared in an order +// that leaves alignment holes, with subclasses that add fields of each width. +// Field reads and writes, virtual dispatch, instanceof, boxing into unknown, +// thrown instances and publication behave the same before and after the +// objects are published. + +abstract class Node implements ProgramLifetime { + readonly kind: number; + flagged = false; + readonly pos: number; + end: number; + parent: Node | undefined = undefined; + constructor(kind: number, pos: number, end: number) { + this.kind = kind; + this.pos = pos; + this.end = end; + } + abstract label(): string; + width(): number { + return this.end - this.pos; + } +} + +class Ident extends Node { + readonly text: string; + id = 0; + escaped = false; + constructor(pos: number, text: string) { + super(1, pos, pos + text.length); + this.text = text; + } + label(): string { + return `Ident(${this.text}#${this.id}${this.escaped ? "!" : ""})`; + } +} + +class Literal extends Node { + readonly value: number; + negative: boolean; + radix = 10; + constructor(pos: number, value: number) { + super(2, pos, pos + String(value).length); + this.value = value; + this.negative = value < 0; + } + label(): string { + return `Literal(${this.value.toString(this.radix)}${this.negative ? "-" : ""})`; + } +} + +class Binary extends Node { + readonly left: Node; + readonly right: Node; + operator: string; + precedence = 0; + parenthesized = false; + constructor(left: Node, operator: string, right: Node) { + super(3, left.pos, right.end); + this.left = left; + this.right = right; + this.operator = operator; + left.parent = this; + right.parent = this; + } + label(): string { + const inner = `${this.left.label()} ${this.operator} ${this.right.label()}`; + return this.parenthesized ? `(${inner})` : inner; + } +} + +class Tagged extends Ident { + tag: string | undefined = undefined; + weight = 0.5; + marked = false; + label(): string { + return `Tagged(${super.label()}, ${this.tag ?? "-"}, ${this.weight}, ${this.marked})`; + } +} + +class Flow implements ProgramLifetime { + flags: number; + node: Node | undefined; + antecedent: Flow | undefined; + reachable = true; + constructor(flags: number, node: Node | undefined, antecedent: Flow | undefined) { + this.flags = flags; + this.node = node; + this.antecedent = antecedent; + } +} + +function describe(n: Node): string { + const parent = n.parent === undefined ? "-" : String(n.parent.kind); + return `${n.label()} k${n.kind} ${n.pos}-${n.end} w${n.width()} ${n.flagged ? "F" : "f"} up ${parent}`; +} + +function classify(value: unknown): string { + if (value instanceof Tagged) return `tagged ${value.tag ?? "-"}`; + if (value instanceof Ident) return `ident ${value.text}`; + if (value instanceof Literal) return `literal ${value.value}`; + if (value instanceof Binary) return `binary ${value.operator}`; + if (value instanceof Flow) return `flow ${value.flags}`; + return typeof value; +} + +function isNode(value: unknown): boolean { + return value instanceof Node; +} + +function isLiteral(value: unknown): boolean { + return value instanceof Literal; +} + +function walk(n: Node, out: string[]): void { + out.push(describe(n)); + if (n instanceof Binary) { + walk(n.left, out); + walk(n.right, out); + } +} + +function build(prefix: string): { root: Binary; flows: Flow[]; idents: Ident[] } { + const a = new Ident(0, `${prefix}a`); + a.id = 7; + const b = new Tagged(4, `${prefix}b`); + b.tag = "t"; + b.weight = 1.25; + b.marked = true; + b.escaped = true; + const lit = new Literal(8, -255); + lit.radix = 16; + const sum = new Binary(a, "+", b); + sum.precedence = 12; + sum.parenthesized = true; + const root = new Binary(sum, "*", lit); + root.precedence = 13; + root.flagged = true; + const flows: Flow[] = []; + let prev: Flow | undefined = undefined; + for (const n of [a, b, lit, sum, root]) { + prev = new Flow(flows.length * 3 + 1, n, prev); + flows.push(prev); + } + flows[2]!.reachable = false; + return { root, flows, idents: [a, b] }; +} + +function report(side: string, g: { root: Binary; flows: Flow[]; idents: Ident[] }): void { + const out: string[] = []; + walk(g.root, out); + for (const line of out) console.log(side, line); + for (const f of g.flows) { + const node = f.node === undefined ? "-" : classify(f.node); + const before = f.antecedent === undefined ? "-" : String(f.antecedent.flags); + console.log(side, "flow", f.flags, f.reachable, node, before); + } + const boxed: unknown[] = [g.root, g.root.left, g.idents[1], g.flows[0], 3, "s"]; + console.log(side, boxed.map(classify).join(", ")); +} + +function attempt(what: string, write: () => void): void { + try { + write(); + console.log(what, "wrote"); + } catch (e) { + console.log(what, (e as Error).name, (e as Error).message); + } +} + +const g = build("x"); +report("before", g); + +// Writes to fields that share an 8-byte word with their neighbours. +g.idents[0]!.id = 9; +g.idents[0]!.escaped = true; +const tagged = g.idents[1] as Tagged; +tagged.marked = false; +tagged.weight = -2; +g.root.flagged = false; +g.flows[1]!.flags = 40; +report("written", g); + +// A thrown instance keeps its class. +try { + throw g.idents[1]; +} catch (e) { + console.log("caught", classify(e), isNode(e), isLiteral(e)); +} + +// Instances as map keys and set members. +const seen = new Map(); +const visited = new Set(); +for (const f of g.flows) { + if (f.node !== undefined) seen.set(f.node, (seen.get(f.node) ?? 0) + f.flags); + visited.add(f); +} +console.log("keys", [...seen.entries()].map(([n, v]) => `${n.kind}:${v}`).join(" "), visited.size); + +const published = publish(g); +console.log("same", published === g, publish(g.root) === g.root); +report("published", published); +attempt("ident id", () => { + published.idents[0]!.id = 1; +}); +attempt("tagged flag", () => { + (published.idents[1] as Tagged).marked = true; +}); +attempt("root flag", () => { + published.root.flagged = true; +}); +attempt("flow reachable", () => { + published.flows[2]!.reachable = true; +}); +report("after writes", published); + +// New instances next to published ones stay writable. +const fresh = new Binary(new Ident(16, "y"), "-", new Literal(20, 3)); +fresh.precedence = 11; +fresh.right.flagged = true; +console.log("fresh", describe(fresh), describe(fresh.right), classify(fresh.left), classify(published.root)); + +// Instances in recycled blocks: freed 8- and 16-byte objects leave their +// blocks on the allocator's free lists, and the next instances of the same +// sizes reuse them. Marker has no fields (a 4-byte instance); Toggle's +// field and Pair's first field sit right after the header. +class Scrap { + readonly n: number; + constructor(n: number) { + this.n = n; + } +} + +class Blank {} + +class Marker implements ProgramLifetime {} + +class Toggle implements ProgramLifetime { + on: boolean; + constructor(on: boolean) { + this.on = on; + } +} + +class Pair implements ProgramLifetime { + readonly low: number; + readonly high: Node | undefined; + constructor(low: number, high: Node | undefined) { + this.low = low | 0; + this.high = high; + } +} + +const blanks: Blank[] = []; +const scraps: Scrap[] = []; +const markers: Marker[] = []; +const toggles: Toggle[] = []; +const pairs: Pair[] = []; +for (let round = 0; round < 3; round++) { + for (let i = 0; i < 200; i++) { + blanks.push(new Blank()); + scraps.push(new Scrap(i * 1.5)); + } + let sum = 0; + for (const s of scraps) sum += s.n; + blanks.length = 0; + scraps.length = 0; + for (let i = 0; i < 200; i++) { + markers.push(new Marker()); + toggles.push(new Toggle(i % 3 === 0)); + pairs.push(new Pair(i - 100, i % 2 === 0 ? undefined : published.root)); + } + let on = 0; + let low = 0; + let high = 0; + for (const t of toggles) if (t.on) on++; + for (const p of pairs) { + low += p.low; + if (p.high !== undefined) high++; + } + console.log("recycled", round, sum, markers.length, on, low, high); +} diff --git a/tests/corpus/6495-key-order-rewritten-literals.ts b/tests/corpus/6495-key-order-rewritten-literals.ts new file mode 100644 index 000000000..1cce1db6a --- /dev/null +++ b/tests/corpus/6495-key-order-rewritten-literals.ts @@ -0,0 +1,54 @@ +// Object literals keep their own key order through JSON.stringify when the +// function that builds them is rewritten by a later lowering pass (here a +// catch block that hands the caught value to an `unknown` parameter). The +// declared type lists the keys in another order; Node writes the literal's. +interface StatusBody { + pool?: string; + cpuModel?: string; + cellId?: string; + hiveId?: string; + cluster?: string; + commit?: string; + idleSince?: number; +} +interface PatchBody { + readyState: string; + status: StatusBody; +} + +function describe(value: unknown): string { + return value instanceof Error ? `Error(${value.message})` : String(value); +} + +function send(path: string, body: string): string { + return `${path} ${body}`; +} + +function write(status: StatusBody): string { + return JSON.stringify(status); +} + +function report(commit: string, idleSince: number | undefined, fail: boolean): string[] { + const lines: string[] = []; + try { + if (fail) throw new Error("probe failed"); + } catch (e) { + lines.push(`caught ${describe(e)}`); + } + lines.push( + send( + "/patch", + JSON.stringify({ + readyState: "DOWNLOADING", + status: { cellId: "c1", cluster: "k1", commit, cpuModel: "m1", hiveId: "h1", idleSince }, + } satisfies PatchBody), + ), + ); + lines.push(write({ cellId: "c2", cluster: "k2", commit, cpuModel: "m2", hiveId: "h2" })); + const body = { hiveId: "h3", commit, cellId: "c3" } satisfies StatusBody; + lines.push(send("/status", JSON.stringify(body)), Object.keys(body).join(",")); + return lines; +} + +for (const line of report("abc", 5, true)) console.log(line); +for (const line of report("def", undefined, false)) console.log(line); diff --git a/tests/corpus/6496-key-order-built-and-parsed.ts b/tests/corpus/6496-key-order-built-and-parsed.ts new file mode 100644 index 000000000..278ecd646 --- /dev/null +++ b/tests/corpus/6496-key-order-built-and-parsed.ts @@ -0,0 +1,35 @@ +// A record type the program both builds with an object literal and reads +// back with a typed JSON.parse: the literal enumerates in its own key order +// (not the declaration's), values parsed from the JSON it wrote keep that +// order, and values parsed from JSON in another order keep theirs. +interface Order { + id: string; + region: string; +} +interface Message { + order: Order; + outputs: string[]; + traceCarrier?: Record; + pollMs: number; +} + +function encode(order: Order, outputs: string[], pollMs: number): string { + const message: Message = { outputs, order, pollMs }; + return JSON.stringify(message); +} + +function decode(text: string): Message { + return JSON.parse(text) as Message; +} + +const wire = encode({ region: "iad1", id: "o1" }, ["a", "b"], 250); +console.log(wire); +const back = decode(wire); +console.log(JSON.stringify(back), Object.keys(back).join(","), Object.keys(back.order).join(",")); + +const external = decode('{"pollMs":5,"order":{"id":"o2","region":"sfo1"},"outputs":[],"traceCarrier":{"t":"1"}}'); +console.log(JSON.stringify(external), Object.keys(external).join(",")); + +const declared = decode('{"order":{"id":"o3","region":"fra1"},"outputs":["x"],"pollMs":1}'); +console.log(JSON.stringify(declared), Object.keys(declared).join(",")); +for (const [key, value] of Object.entries(back)) console.log(key, value !== undefined ? JSON.stringify(value) : "-"); diff --git a/tests/corpus/6580-emitter-typed-untyped-listeners.ts b/tests/corpus/6580-emitter-typed-untyped-listeners.ts new file mode 100644 index 000000000..42f3db549 --- /dev/null +++ b/tests/corpus/6580-emitter-typed-untyped-listeners.ts @@ -0,0 +1,50 @@ +// One native EventEmitter used both through its static type and through an +// untyped reference: typed and untyped listeners of the same event share +// one listener list, so emits from either side reach every listener in +// registration order, with once, prepend, off and listenerCount over the +// whole list. Untyped emits pass their arguments as given (extra ones +// included) to untyped listeners and the event's typed tuple to typed ones. +import { EventEmitter } from "node:events"; + +const e = new EventEmitter(); +const d: any = e; +const log: string[] = []; +const typed = (n: number, s: string): void => { + log.push(`typed ${n} ${s}`); +}; +e.on("tick", typed); +d.on("tick", (...args: any[]) => { + log.push(`untyped ${JSON.stringify(args)}`); +}); +e.once("tick", (n: number) => { + log.push(`typed once ${n}`); +}); +d.once("tick", (n: any) => { + log.push(`untyped once ${n}`); +}); +d.prependListener("tick", () => { + log.push("untyped first"); +}); +console.log(e.listenerCount("tick"), d.listenerCount("tick")); +console.log(e.emit("tick", 1, "a")); +console.log(d.emit("tick", 2, "b", "extra")); +console.log(e.listenerCount("tick"), d.listenerCount("tick"), d.listenerCount("tick", typed)); +d.off("tick", typed); +e.emit("tick", 3, "c"); +d.emit("tick", 4, "d"); +console.log(log.join("\n")); +log.length = 0; + +// A typed prefix listener next to untyped listeners of a longer tuple. +e.on("pair", (a: string) => log.push(`prefix ${a}`)); +d.on("pair", (a: any, b: any) => log.push(`untyped ${a} ${b}`)); +e.emit("pair", "x", 1); +d.emit("pair", "y", 2); +console.log(log.join("\n")); + +// Names only untyped code uses stay untyped, and an emit nobody hears +// answers false. +d.on("loose", (...args: any[]) => console.log("loose", args.length, args)); +console.log(d.emit("loose"), d.emit("loose", null, [1], { k: true })); +console.log(d.emit("nobody", 1), e.emit("tick", 5, "e")); +console.log(d.eventNames(), e.eventNames()); diff --git a/tests/corpus/6581-emitter-untyped-error-and-meta.ts b/tests/corpus/6581-emitter-untyped-error-and-meta.ts new file mode 100644 index 000000000..1cdd10bec --- /dev/null +++ b/tests/corpus/6581-emitter-untyped-error-and-meta.ts @@ -0,0 +1,59 @@ +// 'error', errorMonitor and the newListener/removeListener meta events on +// an emitter used both through its static type and through an untyped +// reference. Untyped emits of 'error' keep Node's contract: listeners of +// either kind receive the payload, and with none an Error throws itself +// while other payloads throw ERR_UNHANDLED_ERROR. Untyped meta listeners +// receive the event name and the listener function. +import { EventEmitter, errorMonitor } from "node:events"; + +const plain: any = new EventEmitter(); +plain.on("newListener", (name: any, fn: any) => console.log("plain newListener", name, typeof fn)); +plain.on("removeListener", (name: any, fn: any) => console.log("plain removeListener", name, fn.name)); +function named(): void {} +plain.on("x", named); +plain.off("x", named); +plain.emit("newListener", "manual", named); + +const e = new EventEmitter(); +const d: any = e; +d.on("newListener", (name: any) => { + console.log("newListener", name); +}); +d.on("removeListener", (name: any) => { + console.log("removeListener", name); +}); +e.on("removeListener", (name: string) => console.log("typed removeListener", name)); +const tap = (n: any): void => console.log("untyped v", n); +d.on("v", tap); +e.on("v", (n: number) => console.log("typed v", n)); +e.emit("v", 1); +d.emit("v", 2); +d.off("v", tap); +e.emit("v", 3); +d.removeAllListeners("v"); +console.log(d.listenerCount("v")); + +d.removeAllListeners("newListener"); +d.removeAllListeners("removeListener"); +const monitor = (err: Error): void => console.log("monitor", err.message); +e.on(errorMonitor, monitor); +e.on("error", (err: Error) => console.log("typed error", err.message)); +d.on("error", (err: any) => console.log("untyped error", err instanceof Error, err.message)); +d.emit("error", new Error("boom")); +e.emit("error", new Error("bang")); +d.removeAllListeners("error"); +try { + d.emit("error", new Error("unhandled")); +} catch (err: any) { + console.log("threw", err instanceof Error, err.message); +} +e.off(errorMonitor, monitor); +for (const payload of ["just text", 42, undefined, null, false]) { + try { + d.emit("error", payload); + } catch (err: any) { + console.log("threw", err.code, err.message); + } +} +d.on("error", (x: any, more: any) => console.log("untyped got", x, more)); +console.log(d.emit("error", 42, "more"), d.listenerCount("error")); diff --git a/tests/corpus/6582-emitter-class-expression-escapes.cjs b/tests/corpus/6582-emitter-class-expression-escapes.cjs new file mode 100644 index 000000000..da641f1b6 --- /dev/null +++ b/tests/corpus/6582-emitter-class-expression-escapes.cjs @@ -0,0 +1,61 @@ +// A function-local class expression extending EventEmitter whose +// instances escape untyped: the class body emits with fixed tuples while +// callers listen, emit and inspect through untyped references. +"use strict"; +const { EventEmitter } = require("node:events"); + +function makeQueue(label) { + const Queue = class extends EventEmitter { + constructor() { + super(); + this.items = []; + } + push(item) { + this.items.push(item); + this.emit("added", item, this.items.length); + return this; + } + drain() { + const items = this.items.splice(0); + this.emit("drained", items.length); + return items; + } + fail(message) { + this.emit("error", new Error(`${label}: ${message}`)); + } + }; + return new Queue(); +} + +const q = makeQueue("q1"); +q.on("added", (item, size) => console.log("added", item, size)); +q.once("added", (item) => console.log("first add", item)); +q.prependListener("drained", (count) => console.log("drained", count)); +q.push("a").push("b"); +q.emit("added", "manual", 99, "extra"); +console.log(q.drain()); +q.on("error", (err) => console.log("error", err.message)); +q.fail("broken"); +console.log(q.listenerCount("added"), q.eventNames(), q.getMaxListeners()); +q.removeAllListeners("added"); +q.push("c"); +console.log(q.listenerCount("added"), q.listeners("drained").length); +q.removeAllListeners(); +console.log(q.eventNames()); + +const q2 = makeQueue("q2"); +try { + q2.fail("nobody listens"); +} catch (err) { + console.log("caught", err.message); +} +const counts = []; +const q3 = makeQueue("q3"); +const count = () => counts.push(q3.listenerCount("added")); +q3.on("added", count); +q3.on("added", function self() { + q3.off("added", self); + counts.push("self"); +}); +q3.push(1).push(2); +console.log(counts); diff --git a/tests/corpus/6583-emitter-untyped-surface.ts b/tests/corpus/6583-emitter-untyped-surface.ts new file mode 100644 index 000000000..18cd3cb91 --- /dev/null +++ b/tests/corpus/6583-emitter-untyped-surface.ts @@ -0,0 +1,47 @@ +// The EventEmitter surface beyond on/once/emit/off called through untyped +// references to compiled emitters (a subclass instance and a plain +// EventEmitter): listenerCount, eventNames, listeners, rawListeners, +// removeAllListeners, setMaxListeners and getMaxListeners answer Node's +// results, and the chaining forms return the emitter. +import { EventEmitter } from "node:events"; + +class Bus extends EventEmitter { + ping(n: number): boolean { + return this.emit("ping", n); + } +} +const b = new Bus(); +const u: unknown = b; +const d = u as any; +const f = (n: any): void => console.log("f", n); +d.on("ping", f); +d.prependOnceListener("ping", (n: any) => console.log("once first", n)); +d.prependListener("ping", (n: any) => console.log("first", n)); +console.log(d.listenerCount("ping"), d.listenerCount("ping", f), d.listenerCount("none")); +console.log(d.eventNames(), d.listenerCount()); +const all = d.listeners("ping"); +console.log(all.length, d.rawListeners("ping").length, all[2] === f, typeof all[0]); +console.log(d.listeners("none")); +console.log(d.getMaxListeners(), d.setMaxListeners(2) === d, b.getMaxListeners()); +console.log(b.ping(1), b.ping(2)); +try { + d.setMaxListeners(-1); +} catch (err: any) { + console.log(err.name, err.code, err.message); +} +try { + d.setMaxListeners("x"); +} catch (err: any) { + console.log(err.name, err.code, err.message); +} +d.on("other", () => {}); +console.log(d.removeAllListeners("ping") === d, d.eventNames()); +console.log(d.removeAllListeners() === d, d.eventNames(), d.listenerCount("other")); +console.log(b.ping(3)); + +const plain: any = new EventEmitter(); +plain.on("x", () => {}); +plain.on("y", () => {}); +console.log(plain.eventNames(), plain.getMaxListeners(), plain.listeners("x").length); +plain.setMaxListeners(0); +console.log(plain.getMaxListeners(), plain.removeAllListeners("x").eventNames()); diff --git a/tests/corpus/6584-emitter-class-body-untyped-listeners.ts b/tests/corpus/6584-emitter-class-body-untyped-listeners.ts new file mode 100644 index 000000000..f2ca78d84 --- /dev/null +++ b/tests/corpus/6584-emitter-class-body-untyped-listeners.ts @@ -0,0 +1,54 @@ +// A class whose methods emit with fixed tuples, observed by listeners +// registered through untyped references: class-instance payloads keep +// their identity, once/off apply across typed and untyped listeners, a +// listener may capture and remove itself, untyped emits reach events.once, +// and errorMonitor sees untyped 'error' emits. +import { EventEmitter, once, errorMonitor } from "node:events"; + +class Job { + readonly tags: string[] = []; + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +class Worker extends EventEmitter { + run(job: Job): void { + this.emit("job", job, job.tags.length); + } + finish(): void { + this.emit("done"); + } +} + +const w = new Worker(); +const u: any = w; +const first = new Job(7); +u.on("job", (j: any, count: any) => { + console.log("untyped job", j.id, count, j === first, j instanceof Job); + j.tags.push("seen"); +}); +w.on("job", (j: Job) => console.log("typed job", j.id, j.tags.join(","))); +u.once("done", () => console.log("done once")); +w.run(first); +w.finish(); +w.finish(); +u.emit("job", new Job(8), 0); +const self = (): void => { + console.log("self", u.listenerCount("job")); + u.off("job", self); +}; +u.on("job", self); +w.run(new Job(9)); +w.run(new Job(10)); +w.on(errorMonitor, (err: Error) => console.log("monitor", err.message)); +w.on("error", (err: Error) => console.log("typed error", err.message)); +u.emit("error", new Error("e1")); +async function main(): Promise { + const pending = once(w, "job"); + u.emit("job", new Job(11), 1); + const [j, count]: any[] = await pending; + console.log("once resolved", j.id, count); +} +main(); diff --git a/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts b/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts new file mode 100644 index 000000000..c42a803f5 --- /dev/null +++ b/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts @@ -0,0 +1,80 @@ +// `e instanceof C` on a catch binding when the thrown value crossed into +// unknown first: a union, an unknown or any value, a rethrown binding and an +// awaited rejection. The narrowed reads and `e as C` read the same instance. +abstract class Shape { + sides = 0; +} +class Polygon extends Shape { + name = "polygon"; +} +class Square extends Polygon { + size = 3; +} +class Plain { + w = 4; +} +class CodedError extends Error { + code = 7; +} + +function report(label: string, e: unknown): void { + console.log(label, "via unknown", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain); +} + +function attempt(label: string, body: () => void): void { + try { + body(); + } catch (e) { + report(label, e); + console.log(label, "binding", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain, e instanceof Error, e instanceof CodedError); + console.log(label, "negated", !(e instanceof Shape), !(e instanceof Error)); + if (e instanceof Polygon) console.log(label, "narrowed", e.name, e.sides); + if (e instanceof Square) console.log(label, "square", e.size); + if (e instanceof CodedError) console.log(label, "coded", e.code, e.message); + if (e instanceof Shape && !(e instanceof Square)) console.log(label, "shape only", (e as Polygon).name); + } +} + +function throwUnknown(value: unknown): never { + throw value; +} +const choose = (n: number): Polygon | Plain | string => (n === 0 ? new Square() : n === 1 ? new Plain() : n === 2 ? new Polygon() : "text"); +const maybe = (n: number): Square | undefined => (n === 0 ? new Square() : undefined); +const failure = (n: number): CodedError | string => (n === 0 ? new CodedError("failed") : "failed"); + +for (let n = 0; n < 4; n++) attempt(`union ${n}`, () => { throw choose(n); }); +attempt("optional", () => { throw maybe(0); }); +attempt("optional undefined", () => { throw maybe(1); }); +attempt("error union", () => { throw failure(0); }); +attempt("error union string", () => { throw failure(1); }); +attempt("unknown", () => throwUnknown(new Square())); +attempt("unknown plain", () => throwUnknown(new Plain())); +attempt("unknown error", () => throwUnknown(new CodedError("unknown"))); +const anyValue: any = new Polygon(); +attempt("any", () => { throw anyValue; }); +attempt("typed", () => { const shape: Shape = new Square(); throw shape; }); +attempt("rethrow", () => { + try { + throw choose(0); + } catch (inner) { + throw inner; + } +}); + +async function main(): Promise { + try { + await Promise.reject(choose(0)); + } catch (e) { + console.log("rejected", e instanceof Shape, e instanceof Polygon, e instanceof Square, e instanceof Plain); + if (e instanceof Square) console.log("rejected square", e.size, e.name); + } + try { + await (async () => { + throw failure(0); + })(); + } catch (e) { + console.log("async error", e instanceof Error, e instanceof CodedError); + if (e instanceof CodedError) console.log("async coded", e.code); + } +} +void main(); diff --git a/tests/corpus/6903-typedarray-of.ts b/tests/corpus/6903-typedarray-of.ts new file mode 100644 index 000000000..bda6b529a --- /dev/null +++ b/tests/corpus/6903-typedarray-of.ts @@ -0,0 +1,34 @@ +// TypedArray.of: a new array of the arguments, each converted with ToNumber +// and then to the element type, in argument order after all arguments are +// evaluated. Covers each numeric flavor and spread arguments; 6904 covers +// values that are not numbers. +const u8 = Uint8Array.of(1, 2, 3); +console.log(u8.length, u8.byteLength, Array.from(u8).join(",")); +console.log(u8 instanceof Uint8Array); + +console.log(Array.from(Uint8Array.of(-1, 256, 257, 3.7, 0 / 0)).join(",")); +console.log(Array.from(Uint8ClampedArray.of(-1, 256, 1.5, 2.5)).join(",")); +console.log(Array.from(Int8Array.of(127, 128, -129)).join(",")); +console.log(Array.from(Uint16Array.of(65536, -1)).join(",")); +console.log(Array.from(Int16Array.of(32768, -32769)).join(",")); +console.log(Array.from(Uint32Array.of(-1, 4294967296)).join(",")); +console.log(Array.from(Int32Array.of(2147483648, 1.9)).join(",")); +console.log(Array.from(Float32Array.of(0.1, 1e40)).join(",")); +console.log(Array.from(Float64Array.of(0.1, -0)).join(",")); + +const empty = Float64Array.of(); +console.log("empty", empty.length); + +const parts = [4, 5, 6]; +console.log(Array.from(Uint8Array.of(...parts)).join(",")); +console.log(Array.from(Int16Array.of(1, ...parts, 7)).join(",")); + +let order = ""; +function next(n: number): number { + order += n; + return n; +} +console.log(Array.from(Uint8Array.of(next(1), next(2), next(3))).join(","), order); + +const bytes = Uint8Array.of(0xde, 0xad, 0xbe, 0xef); +console.log(Buffer.from(bytes).toString("hex")); diff --git a/tests/corpus/6904-typedarray-of-loose.mjs b/tests/corpus/6904-typedarray-of-loose.mjs new file mode 100644 index 000000000..a067b31c7 --- /dev/null +++ b/tests/corpus/6904-typedarray-of-loose.mjs @@ -0,0 +1,37 @@ +// TypedArray.of over values that are not numbers: each argument goes through +// ToNumber (valueOf / toString) in argument order, after all arguments are +// evaluated, and a Symbol throws a TypeError. +console.log(Array.from(Float64Array.of("12", "", " 0x10 ", "abc", true, false, null, undefined)).join(",")); +console.log(Array.from(Float64Array.of([7], [], {}, [1, 2])).join(",")); +const strings = ["12", " 0x10 ", "abc"]; +console.log(Array.from(Float64Array.of(...strings, 1)).join(",")); +console.log(Array.from(Uint8Array.of("300", true, [2])).join(",")); + +let calls = ""; +const a = { valueOf: () => ((calls += "a"), 9) }; +const b = { toString: () => ((calls += "b"), "8") }; +console.log(Array.from(Uint8Array.of(a, b, 7)).join(","), calls); + +let order = ""; +const v = (n) => ({ valueOf: () => ((order += "v" + n), n) }); +const e = (n) => ((order += "e" + n), v(n)); +console.log(Array.from(Int16Array.of(e(1), e(2))).join(","), order); + +try { + Uint8Array.of(1, Symbol("s")); +} catch (err) { + console.log(err instanceof TypeError); +} + +const dyn = JSON.parse("[1, 2, 300]"); +console.log(Array.from(Uint8Array.of(...dyn)).join(",")); +console.log(Array.from(Uint8Array.of(...dyn, "7", ...[8, 9], ..."12")).join(",")); + +for (const text of ["5", "null", "{}"]) { + const value = JSON.parse(text); + try { + Uint8Array.of(...value); + } catch (err) { + console.log(err instanceof TypeError, err.message); + } +} diff --git a/tests/corpus/6907-generic-member-defaulted-callback.ts b/tests/corpus/6907-generic-member-defaulted-callback.ts new file mode 100644 index 000000000..d9ca305a6 --- /dev/null +++ b/tests/corpus/6907-generic-member-defaulted-callback.ts @@ -0,0 +1,33 @@ +// A generic arrow or method held by an object literal returns an object +// whose callback has a defaulted parameter. The callback is typed by the +// declared result (`at?: readonly string[]`), and calls that omit the +// argument use the default, as in Node. +type Codec = { decode: (value: unknown, at?: readonly string[]) => T }; +function describe(codec: C): C { + return codec; +} + +const t = { + literal: (initial: T): Codec => + describe({ + decode: (_value: unknown, at: readonly string[] = []): T => { + console.log("at", at.length === 0 ? "(default)" : at.join(".")); + return initial; + }, + }), + pair(a: A, b: B): Codec<[A, B]> { + return { decode: (_value: unknown, at: readonly string[] = ["root"]) => (console.log(at.join(".")), [a, b]) }; + }, +}; + +console.log(t.literal(42).decode(null)); +console.log(t.literal("text").decode(null, ["a", "b"])); +const flag = t.literal(true); +console.log(flag.decode(undefined), flag.decode(1, [])); +console.log(JSON.stringify(t.pair(1, "x").decode(null))); +console.log(JSON.stringify(t.pair(false, [2]).decode(null, ["p"]))); + +function literal(initial: T): Codec { + return describe({ decode: (_value: unknown, _at: readonly string[] = []): T => initial }); +} +console.log(literal(7).decode(null)); diff --git a/tests/corpus/6930-dyn-buffer-methods.cjs b/tests/corpus/6930-dyn-buffer-methods.cjs new file mode 100644 index 000000000..5167a3188 --- /dev/null +++ b/tests/corpus/6930-dyn-buffer-methods.cjs @@ -0,0 +1,267 @@ +// Buffer methods on Buffers held as checked-dynamic values: a buffer stored +// on `this` by a constructor function, or assigned later, keeps every +// Buffer.prototype method. The same names on other receivers keep their own. +"use strict"; + +function Writer(size) { + this.buf = Buffer.alloc(size); + this.at = 0; +} +Writer.prototype.put = function (text, encoding) { + this.at += this.buf.write(text, this.at, encoding); + return this; +}; + +const w = new Writer(12); +w.put("ab").put("6364", "hex").put("é", "latin1"); +console.log(w.at, w.buf.toString("hex", 0, w.at), w.buf.toString("latin1", 0, w.at)); + +const t = (name, f) => { + try { + const v = f(); + console.log(name, Buffer.isBuffer(v) ? `<${v.toString("hex")}>` : JSON.stringify(v)); + } catch (e) { + console.log(name, e.name, e.code, e.message); + } +}; + +function Holder() { + this.b = Buffer.from("hello world"); +} +const h = new Holder(); +const fresh = () => { + h.b = Buffer.from("hello world"); + return h.b; +}; + +// write overloads, offset and length checks, encodings. +t("write", () => fresh() && h.b.write("HE")); +t("write offset", () => fresh() && [h.b.write("zz", 3), h.b.toString()]); +t("write offset length", () => fresh() && [h.b.write("abcdef", 1, 2), h.b.toString()]); +t("write offset encoding", () => fresh() && [h.b.write("6869", 5, "hex"), h.b.toString()]); +t("write encoding", () => fresh() && [h.b.write("aGk=", "base64"), h.b.toString()]); +t("write length encoding", () => fresh() && [h.b.write("abc", 0, 2, "latin1"), h.b.toString()]); +t("write undefined offset", () => fresh() && [h.b.write("zz", undefined, 1), h.b.toString()]); +t("write null encoding", () => fresh() && h.b.write("ab", 0, 2, null)); +t("write case", () => fresh() && h.b.write("é", 0, 2, "UTF-8")); +t("write split code point", () => fresh() && h.b.write("\u{1F600}", 9)); +t("write not a string", () => fresh() && h.b.write(5)); +t("write unknown", () => fresh() && h.b.write("ab", 0, "nope")); +t("write numeric string offset", () => fresh() && h.b.write("ab", "1")); +t("write fractional offset", () => fresh() && h.b.write("ab", 1.5)); +t("write offset past end", () => fresh() && h.b.write("ab", 12)); +t("write negative length", () => fresh() && h.b.write("ab", 0, -1)); +t("write number encoding", () => fresh() && h.b.write("ab", 0, 2, 5)); + +// fill with strings, numbers, buffers and other values. +t("fill string", () => fresh() && h.b.fill("ab", 0, 4)); +t("fill number", () => fresh() && h.b.fill(0x41, 4, 6)); +t("fill 300", () => fresh() && h.b.fill(300)); +t("fill true", () => fresh() && h.b.fill(true, 1, 3)); +t("fill object", () => fresh() && h.b.fill({}, 9)); +t("fill buffer", () => fresh() && h.b.fill(Buffer.from([1, 2]))); +t("fill encoding", () => fresh() && h.b.fill("ab", "utf16le")); +t("fill end encoding", () => fresh() && h.b.fill("ab", 1, "latin1")); +t("fill empty string", () => fresh() && h.b.fill("", 2)); +t("fill odd hex", () => fresh() && h.b.fill("abc", "hex")); +t("fill invalid hex", () => fresh() && h.b.fill("zz", "hex")); +t("fill empty buffer", () => fresh() && h.b.fill(Buffer.alloc(0))); +t("fill number encoding", () => fresh() && h.b.fill("a", 0, 1, 5)); +t("fill string offset", () => fresh() && h.b.fill(1, "2")); +t("fill end past length", () => fresh() && h.b.fill(1, 0, 20)); +t("fill offset past end", () => fresh() && h.b.fill(1, 20)); +t("fill empty range", () => fresh() && h.b.fill(1, 4, 2)); + +// toString windows and encodings. +t("toString window", () => fresh() && h.b.toString("hex", 1, 3)); +t("toString numeric strings", () => fresh() && h.b.toString("utf8", "1", "3")); +t("toString fractions", () => fresh() && h.b.toString("utf8", 0.9, 2.9)); +t("toString NaN", () => fresh() && h.b.toString("utf8", "x", "y")); +t("toString negative", () => fresh() && h.b.toString("utf8", -1, 2)); +t("toString undefined encoding", () => fresh() && h.b.toString(undefined, 6)); +t("toString empty unknown", () => fresh() && h.b.toString("nope", 3, 1)); +t("toString null encoding", () => fresh() && h.b.toString(null, 0, 1)); + +// The other Buffer methods. +t("toJSON", () => fresh() && h.b.toJSON()); +t("equals", () => fresh() && [h.b.equals(Buffer.from("hello world")), h.b.equals(Buffer.from("hello"))]); +t("equals string", () => fresh() && h.b.equals("hello world")); +t("compare", () => fresh() && [h.b.compare(Buffer.from("hellp")), h.b.compare(Buffer.from("xello"), 1, 5, 1, 5)]); +t("compare string", () => fresh() && h.b.compare("hello")); +t("copy", () => { + const d = Buffer.alloc(4); + return fresh() && [h.b.copy(d, 1, 6), d.toString()]; +}); +t("copy coerced offsets", () => { + const d = Buffer.alloc(4); + return fresh() && [h.b.copy(d, 1.7, "1", 3), d.toString("hex")]; +}); +t("copy NaN offsets", () => fresh() && h.b.copy(Buffer.alloc(2), NaN, "x")); +t("copy into Uint8Array", () => { + const d = new Uint8Array(3); + return fresh() && [h.b.copy(d), Array.from(d)]; +}); +t("copy negative target start", () => fresh() && h.b.copy(Buffer.alloc(2), -1)); +t("copy source start past end", () => fresh() && h.b.copy(Buffer.alloc(2), 0, 20)); +t("copy into array", () => fresh() && h.b.copy([1, 2])); +t("indexOf", () => fresh() && [h.b.indexOf("o"), h.b.indexOf("o", 5), h.b.indexOf("o", -4), h.b.indexOf(0x77)]); +t("indexOf buffer", () => fresh() && h.b.indexOf(Buffer.from("wor"))); +t("indexOf encoding", () => fresh() && [h.b.indexOf("6c6c", "hex"), h.b.indexOf("l", 0, "latin1")]); +t("indexOf utf16le", () => Buffer.from("abcabc", "utf16le").indexOf("b", 0, "utf16le")); +t("indexOf unknown", () => fresh() && h.b.indexOf("l", 0, "nope")); +t("indexOf number ignores encoding", () => fresh() && h.b.indexOf(0x6c, 0, "nope")); +t("indexOf object", () => fresh() && h.b.indexOf({})); +t("lastIndexOf", () => fresh() && [h.b.lastIndexOf("o"), h.b.lastIndexOf("o", 5), h.b.lastIndexOf("l", "latin1")]); +t("lastIndexOf string offset", () => fresh() && h.b.lastIndexOf("o", "x")); +t("includes", () => fresh() && [h.b.includes("wor"), h.b.includes("z"), h.b.includes("h", null)]); +t("swap16", () => { + h.b = Buffer.from("abcd"); + return h.b.swap16(); +}); +t("swap32 odd", () => { + h.b = Buffer.from("abc"); + return h.b.swap32(); +}); +t("readUInt16LE", () => fresh() && h.b.readUInt16LE(1)); + +// A buffer assigned to a constructor-function instance after construction. +function Later() {} +const later = new Later(); +later.buf = Buffer.alloc(4); +later.buf.write("cd", 1, "ascii"); +console.log("later", later.buf.toString("hex"), later.buf.fill(7).toString("hex")); + +// The same method names on receivers that are not Buffers. +function Box() { + this.text = "banana"; + this.list = [1, 2, 3]; + this.bytes = new Uint8Array([5, 6, 7]); + this.sink = { written: [], write(s) { this.written.push(s); return true; }, toString(a, b) { return `sink ${a} ${b}`; } }; +} +const box = new Box(); +console.log(box.text.indexOf("an"), box.text.lastIndexOf("an"), box.text.includes("nan"), box.text.toString()); +console.log(box.list.indexOf(2), box.list.includes(4), box.list.fill(0, 1).join(","), box.list.toString()); +console.log(Array.from(box.bytes.fill(9, 2)), box.bytes.toString()); +console.log(box.sink.write("x"), box.sink.written, box.sink.toString(1, 2)); + +// fill with other ArrayBufferViews repeats their raw bytes; an empty view is +// ERR_INVALID_ARG_VALUE with Node's inspect of the view. +const tm = (name, f) => { + try { + const v = f(); + console.log(name, Buffer.isBuffer(v) ? `<${v.toString("hex")}>` : JSON.stringify(v)); + } catch (e) { + console.log(name, e.name, e.code, JSON.stringify(e.message)); + } +}; +function Held(bytes) { + this.b = bytes; +} +const held = (bytes) => new Held(bytes).b; +const filled = (n) => held(Buffer.alloc(n)); +tm("fill Uint16Array", () => filled(6).fill(new Uint16Array([0x0102]))); +tm("fill DataView", () => filled(6).fill(new DataView(new Uint8Array([7, 8, 9]).buffer))); +tm("fill DataView window", () => filled(6).fill(new DataView(new Uint8Array([1, 2, 3, 4]).buffer, 1, 2))); +tm("fill Float64Array", () => filled(10).fill(new Float64Array([1]))); +tm("fill subarray", () => filled(6).fill(new Uint16Array([1, 2, 3]).subarray(1))); +tm("fill Int8Array range", () => filled(6).fill(new Int8Array([-1, 2]), 1, 4)); +tm("fill empty Uint16Array", () => filled(6).fill(new Uint16Array(0))); +tm("fill empty Uint8Array", () => filled(6).fill(new Uint8Array(0))); +for (const [size, offset] of [[0, 0], [1, 1], [6, 6], [7, 7], [8, 8], [120, 3], [101, 101]]) + tm(`fill empty DataView ${size}/${offset}`, () => filled(6).fill(new DataView(new ArrayBuffer(size), offset, 0))); +tm("fill empty DataView over bytes", () => filled(6).fill(new DataView(new Uint8Array(200).fill(171).buffer, 7, 0))); + +// Buffer needles under utf16le search whole 2-byte units. +const units = held(Buffer.from("abcabc", "utf16le")); +const split = held(Buffer.from([0x61, 0x00, 0x62, 0x00, 0x00, 0x62, 0x00, 0x63])); +const N = (bytes) => Buffer.from(bytes); +tm("indexOf utf16le needle", () => [split.indexOf(N([0x62, 0x00]), 0, "ucs2"), split.indexOf(N([0x62, 0x00]))]); +tm("indexOf utf16le odd position", () => [ + split.indexOf(N([0x00, 0x62]), 0, "ucs2"), + split.indexOf(N([0x00, 0x62]), 0, "UTF-16LE"), + split.indexOf(N([0x00, 0x62])), +]); +tm("lastIndexOf utf16le needle", () => [ + units.lastIndexOf(N([0x62, 0x00]), undefined, "ucs2"), + split.lastIndexOf(N([0x00, 0x62]), undefined, "ucs2"), + ...[5, 3, 2, 1, -100, 100].map((at) => split.lastIndexOf(N([0x62, 0x00]), at, "ucs2")), +]); +tm("indexOf utf16le offsets", () => [1, 2, 3, -5, 7, 2.9].map((at) => split.indexOf(N([0x62, 0x00]), at, "ucs2"))); +tm("indexOf utf16le short needles", () => [ + split.indexOf(N([0x62]), 0, "ucs2"), + held(N([0x62])).indexOf(N([0x62]), 0, "ucs2"), + split.indexOf(Buffer.alloc(0), 3, "ucs2"), + split.indexOf(Buffer.alloc(0), 30, "ucs2"), + split.indexOf(N([0x62, 0, 0]), 0, "ucs2"), +]); +tm("indexOf utf16le odd lengths", () => [ + held(N([1, 0, 2, 0, 3])).indexOf(N([2, 0, 3]), 0, "ucs2"), + held(N([1, 0, 2, 0, 3, 0])).indexOf(N([2, 0, 3]), 0, "ucs2"), + held(N([1, 2, 1, 2, 1, 2])).indexOf(N([2, 1, 2]), 0, "ucs2"), + held(N([1, 0, 2, 0, 3])).lastIndexOf(N([3]), undefined, "ucs2"), + held(N([0, 2, 0, 2, 0])).lastIndexOf(N([2, 0]), undefined, "ucs2"), +]); +tm("indexOf buffer needle other encodings", () => [ + split.indexOf(N([0x62, 0x00]), 0, "nope"), + split.indexOf(N([0x00, 0x62]), 0, "nope"), + split.indexOf(N([0x00, 0x62]), 0, "latin1"), +]); +tm("indexOf utf16le strings", () => [ + split.indexOf("b", 0, "ucs2"), + units.lastIndexOf("b", undefined, "ucs2"), + units.indexOf("b", 3, "ucs2"), + units.lastIndexOf("b", 3, "ucs2"), + units.indexOf("", 3, "ucs2"), + units.indexOf("", 30, "ucs2"), + units.lastIndexOf("", 30, "ucs2"), + split.indexOf("戀", 0, "ucs2"), +]); +tm("includes utf16le needle", () => [split.includes(N([0x00, 0x62]), 0, "ucs2"), split.includes(N([0x62, 0x00]), 3, "ucs2")]); + +// toString windows with infinite and out-of-range bounds. +const word = held(Buffer.from("hello")); +tm("toString infinite bounds", () => [ + word.toString("utf8", 1, Infinity), + word.toString("utf8", -Infinity, 2), + word.toString("utf8", Infinity), + word.toString("utf8", NaN, 3), + word.toString("utf8", 1e300, 2), + word.toString("utf8", 1, 1e300), + word.toString("utf8", -1e300, 1e300), + word.toString("utf8", 0, -Infinity), + word.toString("utf8", 2, NaN), + word.toString("utf8", 0.5, 4.5), + word.toString("hex", -0.5, -0), + word.toString("latin1", 4.9), + word.toString(undefined, undefined, Infinity), +]); + +// The same searches and windows on Buffers the compiler types statically. +const typed = Buffer.from([0x61, 0x00, 0x62, 0x00, 0x00, 0x62, 0x00, 0x63]); +const typedUnits = Buffer.from("abcabc", "utf16le"); +console.log( + "typed utf16le", + typed.indexOf(Buffer.from([0x00, 0x62]), 0, "ucs2"), + Buffer.from([0x62]).indexOf(Buffer.from([0x62]), 0, "ucs2"), + typed.lastIndexOf(Buffer.from([0x62, 0x00]), 5, "ucs2"), + typed.indexOf(Buffer.from([0x62, 0x00]), 3, "ucs2"), + typed.includes(Buffer.from([0x00, 0x62]), 0, "utf16le"), + typed.indexOf("b", 0, "ucs2"), + typedUnits.lastIndexOf("b", 100, "ucs2"), + typedUnits.lastIndexOf("b", 3, "ucs2"), + typedUnits.indexOf("", 30, "ucs2"), + typedUnits.indexOf("b", 1e300, "ucs2"), + typedUnits.indexOf("c", -1e300, "ucs2"), +); +const typedWord = Buffer.from("hello"); +console.log( + "typed windows", + JSON.stringify([ + typedWord.toString("utf8", 1, Infinity), + typedWord.toString("utf8", -Infinity, 2), + typedWord.toString("utf8", Infinity), + typedWord.toString("utf8", NaN, 3), + typedWord.toString("utf8", 1, 1e300), + typedWord.toString("hex", 0, -Infinity), + ]), +); diff --git a/tests/corpus/6930-fs-promises-rm-options.ts b/tests/corpus/6930-fs-promises-rm-options.ts new file mode 100644 index 000000000..e6de67fdd --- /dev/null +++ b/tests/corpus/6930-fs-promises-rm-options.ts @@ -0,0 +1,99 @@ +// fs.promises.rm with an options object: { recursive, force } and the +// maxRetries/retryDelay pair, as rmSync accepts them. Failures reject with +// Node's errors: a missing path without force, a directory without +// recursive (ERR_FS_EISDIR), and an out-of-range maxRetries or retryDelay. +// rmSync throws the same errors, checking its options before the path, +// while fs.promises.rm checks the path first. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fsp-rm-")); + +function report(label: string, error: unknown): void { + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + console.log(label, error.name, code, error.message.split(dir).join("")); + } else { + console.log(label, "non-error"); + } +} + +async function settle(label: string, run: () => Promise): Promise { + try { + await run(); + console.log(label, "fulfilled"); + } catch (error) { + report(label, error); + } +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "returned"); + } catch (error) { + report(label, error); + } +} + +function tree(root: string): void { + fs.mkdirSync(path.join(root, "a", "b"), { recursive: true }); + fs.writeFileSync(path.join(root, "a", "b", "leaf.txt"), "leaf"); + fs.writeFileSync(path.join(root, "top.txt"), "top"); +} + +async function main(): Promise { + const missing = path.join(dir, "missing"); + await settle("missing force", () => fsp.rm(missing, { recursive: true, force: true })); + await settle("missing force only", () => fsp.rm(missing, { force: true })); + await settle("missing", () => fsp.rm(missing, { recursive: true })); + await settle("missing empty options", () => fsp.rm(missing, {})); + + const file = path.join(dir, "file.txt"); + fs.writeFileSync(file, "x"); + await settle("file", () => fsp.rm(file, { force: false })); + console.log("file exists", fs.existsSync(file)); + + const nested = path.join(dir, "nested"); + tree(nested); + await settle("dir without recursive", () => fsp.rm(nested, { force: true })); + console.log("dir exists", fs.existsSync(nested)); + await settle("dir recursive", () => fsp.rm(nested, { recursive: true })); + console.log("dir exists", fs.existsSync(nested)); + tree(nested); + await settle("fs.promises.rm", () => fs.promises.rm(nested, { recursive: true, force: true })); + console.log("dir exists", fs.existsSync(nested)); + + const retried = path.join(dir, "retried"); + tree(retried); + await settle("retry", () => fsp.rm(retried, { recursive: true, force: true, maxRetries: 2, retryDelay: 10 })); + console.log("retried exists", fs.existsSync(retried)); + await settle("retry missing", () => fsp.rm(retried, { maxRetries: 1 })); + await settle("negative retries", () => fsp.rm(retried, { force: true, maxRetries: -1 })); + await settle("fractional delay", () => fsp.rm(retried, { force: true, retryDelay: 1.5 })); + + await settle("null byte and range", () => fsp.rm("a\0b", { maxRetries: -1 })); + await settle("too many retries", () => fsp.rm(retried, { force: true, maxRetries: 2 ** 32 })); + await settle("long delay", () => fsp.rm(retried, { force: true, retryDelay: 2 ** 31 })); + await settle("delay before retries", () => fsp.rm(retried, { maxRetries: -1, retryDelay: 0.5 })); + + tree(nested); + attempt("rmSync dir", () => fs.rmSync(nested)); + attempt("rmSync dir force", () => fs.rmSync(nested, { force: true })); + attempt("rmSync null byte and range", () => fs.rmSync("a\0b", { maxRetries: -1 })); + attempt("rmSync negative delay", () => fs.rmSync(nested, { recursive: true, retryDelay: -1 })); + attempt("rmSync retry", () => fs.rmSync(nested, { recursive: true, maxRetries: 1 })); + console.log("dir exists", fs.existsSync(nested)); + + // The promise is created before the await, and a rejection is caught + // through .catch like any other promise. + const pending = fsp.rm(missing, { recursive: false }); + await pending.catch((error: unknown) => report("caught", error)); + + fs.rmSync(dir, { recursive: true, force: true }); + console.log("done", fs.existsSync(dir)); +} + +void main(); diff --git a/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts b/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts new file mode 100644 index 000000000..89c4dc70f --- /dev/null +++ b/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts @@ -0,0 +1,23 @@ +// Emitting through an `any` reference reaches listeners that typed code +// registered with no parameters, for an event no typed code emits: they +// run once per emit, whatever arguments the untyped emit passes. + +import { EventEmitter } from "node:events"; + +const emitter = new EventEmitter(); +let ready = 0; +emitter.on("ready", () => { + ready++; + console.log("ready", ready); +}); +emitter.once("closed", function () { + console.log("closed once"); +}); + +const loose: any = emitter; +console.log(loose.emit("ready")); +console.log(loose.emit("ready", 1, "two")); +console.log(loose.emit("closed", { reason: "done" })); +console.log(loose.emit("closed")); +console.log(loose.emit("missing")); +console.log(emitter.listenerCount("ready"), emitter.listenerCount("closed"), ready); diff --git a/tests/corpus/function-integrity-names.ts b/tests/corpus/function-integrity-names.ts new file mode 100644 index 000000000..eb72a8691 --- /dev/null +++ b/tests/corpus/function-integrity-names.ts @@ -0,0 +1,21 @@ +// Integrity operations on typed functions keep their names: the first one +// creates the function's property table, whose `name` must be the +// function's own. Queries on functions no operation has touched answer +// without creating a table. +function hashC(x: number): number { + return x + 1; +} +const arrow = (s: string): string => s + "!"; +const untouched = (a: number, b: number): number => a * b; +console.log(Object.isFrozen(untouched), Object.isSealed(untouched), Object.isExtensible(untouched)); +Object.freeze(hashC); +Object.seal(arrow); +console.log(Object.isFrozen(hashC), Object.isSealed(hashC), Object.isExtensible(hashC), hashC(1)); +console.log(Object.isFrozen(arrow), Object.isSealed(arrow), Object.isExtensible(arrow), arrow("a")); +function later(): number { + return 7; +} +Object.preventExtensions(later); +console.log(Object.isFrozen(later), Object.isSealed(later), Object.isExtensible(later), later()); +const functions: any[] = [hashC, arrow, untouched, later]; +console.log(functions.map((f) => `${f.name}/${f.length}`).join(" ")); diff --git a/tests/corpus/function-integrity.js b/tests/corpus/function-integrity.js new file mode 100644 index 000000000..59a5fbfec --- /dev/null +++ b/tests/corpus/function-integrity.js @@ -0,0 +1,45 @@ +// Object.freeze, seal and preventExtensions on functions, and the queries: +// they act on the function's own properties (name, length, prototype, and +// the ones a program assigns), as in Node. A frozen function still calls, +// and its properties still read. +const createHasher = (block) => { + const hash = (msg) => msg.length * block; + hash.outputLen = 32; + hash.blockLen = block; + hash.create = () => hash(" ".repeat(block)); + return Object.freeze(hash); +}; +const sha = createHasher(64); +console.log(sha("abc"), sha.outputLen, sha.blockLen, sha.create(), sha.name, sha.length); +console.log(Object.isFrozen(sha), Object.isSealed(sha), Object.isExtensible(sha)); +try { + sha.extra = 1; +} catch (error) { + console.log(error.name, error.message); +} +try { + sha.outputLen = 64; +} catch (error) { + console.log(error.name); +} +console.log(sha.outputLen, sha.extra); + +function plain(a, b) { + return a + b; +} +console.log(Object.isFrozen(plain), Object.isSealed(plain), Object.isExtensible(plain)); +plain.tag = "t"; +Object.seal(plain); +console.log(Object.isFrozen(plain), Object.isSealed(plain), Object.isExtensible(plain)); +plain.tag = "u"; +console.log(plain.tag, plain(1, 2), plain.name, plain.length); + +const stopped = () => 0; +Object.preventExtensions(stopped); +console.log(Object.isFrozen(stopped), Object.isSealed(stopped), Object.isExtensible(stopped)); +try { + stopped.late = 1; +} catch (error) { + console.log(error.name, error.message); +} +console.log(stopped.name, stopped.length); diff --git a/tests/corpus/object-literal-tostring-template.ts b/tests/corpus/object-literal-tostring-template.ts new file mode 100644 index 000000000..f87d407f8 --- /dev/null +++ b/tests/corpus/object-literal-tostring-template.ts @@ -0,0 +1,31 @@ +// Template literals and String() call the toString of class instances and +// of object literals that define one, and print "[object Object]" for +// objects that inherit Object.prototype.toString. +class Point { + x: number; + constructor(x: number) { + this.x = x; + } + toString(): string { + return `Point(${this.x})`; + } +} +class Bare { + y: number; + constructor(y: number) { + this.y = y; + } +} +const labeled = { + v: 1, + toString() { + return "labeled" + this.v; + }, +}; +const plain = { v: 2 }; +const p = new Point(3); +const b = new Bare(4); +console.log(`${p}`, `${b}`, `${labeled}`, `${plain}`); +console.log(String(p), String(labeled), p + "", labeled + "", "" + b); +const maybe: Point | undefined = p; +console.log(`${maybe}`); diff --git a/tests/corpus/prototype-tostring-conversions.cjs b/tests/corpus/prototype-tostring-conversions.cjs new file mode 100644 index 000000000..562f1ae9a --- /dev/null +++ b/tests/corpus/prototype-tostring-conversions.cjs @@ -0,0 +1,78 @@ +'use strict'; +// ToString of objects whose toString or valueOf comes from the object or +// its prototype chain: template literals, join, String(), concatenation, +// Array.prototype.toString and the default sort order call it, as in Node. +function RefNum(num) { this.num = num; } +RefNum.prototype.toString = function () { return "R" + this.num; }; +function Inherit() {} +Inherit.prototype = Object.create(RefNum.prototype); +Inherit.prototype.num = 9; + +const r = new RefNum(5); +const deep = new Inherit(); +const own = { toString() { return "own"; } }; +const viaValueOf = { valueOf() { return 42; } }; +const both = { toString() { return "ts"; }, valueOf() { return 7; } }; +const plain = {}; +const m = new Map([[1, r]]); + +console.log(`${r}`, `${deep}`, `${own}`, `${viaValueOf}`, `${both}`, `${plain}`); +console.log(`${m.get(1) || 0}`, `${m.get(2) || 0}`, r * 2); +console.log([r, deep, own, both, plain].join("|")); +console.log([r, own].toString(), String([r, own]), `${[r, own]}`); +console.log([[r, [own]], null, undefined, deep].join(";")); +console.log(String(r), r + "", own + "", both + "", viaValueOf + "", "x".concat(r)); +console.log([new RefNum(3), new RefNum(1), own, new RefNum(2)].sort().join(",")); + +// A toString returning an object falls back to valueOf; a null prototype +// leaves neither, which throws. +const objectResult = { toString() { return {}; }, valueOf() { return "vo"; } }; +console.log(`${objectResult}`, [objectResult].join()); +const bare = Object.create(null); +try { console.log(`${bare}`); } catch (e) { console.log(e.name, e.message); } +try { console.log([bare].join()); } catch (e) { console.log(e.name, e.message); } + +// An exception from toString propagates out of each conversion. +const thrower = { toString() { throw new Error("boom"); } }; +try { console.log(`${thrower}`); } catch (e) { console.log("template", e.message); } +try { console.log([thrower].join()); } catch (e) { console.log("join", e.message); } +try { console.log(String([thrower])); } catch (e) { console.log("String", e.message); } +try { console.log([new RefNum(1), thrower].sort()); } catch (e) { console.log("sort", e.message); } + +// The conversions read the array's length once: elements an element's +// toString appends are not converted, an index it removes reads empty, and +// the first exception ends the conversion before later elements convert. +const growing = []; +growing.push({ toString() { growing.push("late"); return "g"; } }, "b"); +console.log(`${growing}`, growing.length); +console.log(growing.join("|"), growing.length); +console.log(String(growing), growing.length); +const shrinking = []; +shrinking.push({ toString() { shrinking.length = 1; return "s"; } }, "gone", "also"); +console.log(`${shrinking}`, shrinking.length); +shrinking.push("gone", "also"); +console.log(shrinking.join("|"), shrinking.length); +shrinking.push("gone", "also"); +console.log(String(shrinking), shrinking.length); +const visited = []; +const stops = []; +stops.push( + { toString() { throw new Error("first"); } }, + { toString() { visited.push("second"); return "second"; } }, +); +try { console.log(`${stops}`); } catch (e) { console.log("template", e.message, visited.length); } +try { console.log(stops.join()); } catch (e) { console.log("join", e.message, visited.length); } +try { console.log(String(stops)); } catch (e) { console.log("String", e.message, visited.length); } +try { console.log(`${[stops]}`); } catch (e) { console.log("nested", e.message, visited.length); } + +// A hole reads through the prototype: a getter there that throws ends each +// conversion with its exception. +const holeyProto = Object.create(Array.prototype); +Object.defineProperty(holeyProto, '1', { get() { throw new Error('getter'); } }); +const holey = []; +holey[0] = 'a'; +holey[2] = 'c'; +Object.setPrototypeOf(holey, holeyProto); +try { console.log(String(holey)); } catch (e) { console.log('String', e.message); } +try { console.log(holey.join('|')); } catch (e) { console.log('join', e.message); } +try { console.log(`${holey}`); } catch (e) { console.log('template', e.message); } diff --git a/tests/corpus/wrapped-assignment-targets.ts b/tests/corpus/wrapped-assignment-targets.ts new file mode 100644 index 000000000..203a29429 --- /dev/null +++ b/tests/corpus/wrapped-assignment-targets.ts @@ -0,0 +1,54 @@ +// Assignment, update and for-of targets wrapped in parentheses or non-null +// assertions write the reference inside them, as in JavaScript, and the +// writes count for every analysis of the binding or member they write. +const h = { sums: [1, 2, 3] }; +const k = 1; +h.sums[k]! += 10; +console.log(h.sums.join(",")); +let x = 1; +(x) = 2; +console.log(x); +let y: number | undefined = 1; +y! = 5; +console.log(y); +const a = [1, 2]; +(a[0]) += 1; +a[1]! = 7; +a[0]!++; +++(a[1]!); +console.log(a.join(",")); +const o: { n?: number } = { n: 1 }; +o.n! = 4; +(o.n)! *= 2; +console.log(o.n); +let counter = 0; +for ((counter) of [3, 4]) {} +console.log(counter); +const m = new Map([["a", [1]]]); +m.get("a")![0]! += 4; +console.log(m.get("a")!.join(",")); +const r = (a[0]! += 2); +console.log(r, a[0]); +let limit = 3; +function bump(): void { + (limit) = 9; +} +bump(); +console.log(limit); +class Counter { + count: number | undefined = 0; + readonly fixed = 1; + inc(): void { + this.count! += 1; + (this.count)!++; + } +} +const c = new Counter(); +c.inc(); +console.log(c.count, c.fixed); +let flag = false; +const set = (): void => { + flag! = true; +}; +set(); +console.log(flag); diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts b/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts new file mode 100644 index 000000000..99bbe020a --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/main.ts @@ -0,0 +1,9 @@ +// A tsc-compiled dependency wraps builtin modules in its interop helpers +// (`const events_1 = __importDefault(require("events"))`, then +// `class Bus extends events_1.default.EventEmitter`; `const path = +// __importStar(require("path"))`). +import { Bus, where } from "gttscinterop"; + +const bus = new Bus(); +bus.on("ping", (name: string) => console.log("got", name)); +console.log(bus.ping("a"), where("/x/y.txt")); diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.d.ts b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.d.ts new file mode 100644 index 000000000..cebef9119 --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.d.ts @@ -0,0 +1 @@ +export default function suffix(): string; diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.js b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.js new file mode 100644 index 000000000..9c847c6ac --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/helper.js @@ -0,0 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = suffix; +function suffix() { + return "!"; +} diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.d.ts b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.d.ts new file mode 100644 index 000000000..f185ed258 --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.d.ts @@ -0,0 +1,5 @@ +import events from "events"; +export declare class Bus extends events.EventEmitter { + ping(name: string): string; +} +export declare function where(p: string): string; diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.js b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.js new file mode 100644 index 000000000..96450e0e5 --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/lib/index.js @@ -0,0 +1,43 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Bus = void 0; +exports.where = where; +const events_1 = __importDefault(require("events")); +const path = __importStar(require("path")); +const helper_1 = __importDefault(require("./helper")); +class Bus extends events_1.default.EventEmitter { + ping(name) { + this.emit("ping", name); + return `pong ${name}${(0, helper_1.default)()}`; + } +} +exports.Bus = Bus; +function where(p) { + return path.basename(p) + (0, helper_1.default)(); +} diff --git a/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/package.json b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/package.json new file mode 100644 index 000000000..bc83710c8 --- /dev/null +++ b/tests/fixtures/npm/cases/tsc-builtin-interop/node_modules/gttscinterop/package.json @@ -0,0 +1,7 @@ +{ + "name": "gttscinterop", + "version": "1.0.0", + "description": "real tsc 5.6 output (module commonjs, esModuleInterop) of a source that default-imports `events` and namespace-imports `path`: the builtins are wrapped in tsc's __importDefault/__importStar helpers, which probe the module's top-level `this`", + "main": "lib/index.js", + "types": "lib/index.d.ts" +} diff --git a/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json new file mode 100644 index 000000000..4eba5d094 --- /dev/null +++ b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json @@ -0,0 +1 @@ +{ "plain": 4944, "san": 7757 } diff --git a/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json b/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json new file mode 100644 index 000000000..2809fc670 --- /dev/null +++ b/tests/harness/corpus-costs/6819-caught-instanceof-dynamic-payloads.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 30100 } diff --git a/tests/harness/corpus-costs/6930-dyn-buffer-methods.cjs.json b/tests/harness/corpus-costs/6930-dyn-buffer-methods.cjs.json new file mode 100644 index 000000000..bd8f85745 --- /dev/null +++ b/tests/harness/corpus-costs/6930-dyn-buffer-methods.cjs.json @@ -0,0 +1 @@ +{ "plain": 12000, "san": 39000 } diff --git a/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json b/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json new file mode 100644 index 000000000..738c081aa --- /dev/null +++ b/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json @@ -0,0 +1 @@ +{ "plain": 1965, "san": 46298 } diff --git a/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json b/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json new file mode 100644 index 000000000..21b784250 --- /dev/null +++ b/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 3400 } diff --git a/tests/harness/corpus-costs/function-integrity-names.ts.json b/tests/harness/corpus-costs/function-integrity-names.ts.json new file mode 100644 index 000000000..9cc950b57 --- /dev/null +++ b/tests/harness/corpus-costs/function-integrity-names.ts.json @@ -0,0 +1 @@ +{ "plain": 3907, "san": 4954 } diff --git a/tests/harness/corpus-costs/function-integrity.js.json b/tests/harness/corpus-costs/function-integrity.js.json new file mode 100644 index 000000000..1970272f3 --- /dev/null +++ b/tests/harness/corpus-costs/function-integrity.js.json @@ -0,0 +1 @@ +{ "plain": 3345, "san": 3989 } diff --git a/tests/harness/corpus-costs/object-literal-tostring-template.ts.json b/tests/harness/corpus-costs/object-literal-tostring-template.ts.json new file mode 100644 index 000000000..d9a03264c --- /dev/null +++ b/tests/harness/corpus-costs/object-literal-tostring-template.ts.json @@ -0,0 +1 @@ +{ "plain": 3551, "san": 5414 } diff --git a/tests/harness/corpus-costs/prototype-tostring-conversions.cjs.json b/tests/harness/corpus-costs/prototype-tostring-conversions.cjs.json new file mode 100644 index 000000000..1a9f924ff --- /dev/null +++ b/tests/harness/corpus-costs/prototype-tostring-conversions.cjs.json @@ -0,0 +1 @@ +{ "plain": 4028, "san": 4282 } diff --git a/tests/harness/webassembly-untranslated.test.ts b/tests/harness/webassembly-untranslated.test.ts new file mode 100644 index 000000000..a12c75a19 --- /dev/null +++ b/tests/harness/webassembly-untranslated.test.ts @@ -0,0 +1,97 @@ +/* The CompileError for WebAssembly bytes that have no translation — + * scriptc-only. + * + * A module the build finds but does not translate (one that uses a + * proposal outside the translated feature set, or an invalid one) rejects + * with a CompileError that names the reason the build recorded. Bytes the + * build did not see reject with the message for unseen bytes. Both stay + * catchable CompileErrors, and WebAssembly.validate answers false for + * them. + */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +// (module (func i32.const 0 i8x16.splat drop)) +const simdModule = "AGFzbQEAAAABBAFgAAADAgEACgkBBwBBAP0PGgs="; + +const source = `const simd = Buffer.from("${simdModule}", "base64"); +// A type section whose entry is not a function type, so the module is +// malformed. +const invalid = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 2, 1, 64]); +// A custom section whose last byte is computed when the program runs, so +// the build does not see these bytes. +const unseen = Uint8Array.from([0, 97, 115, 109, 1, 0, 0, 0, 0, 4, 2, 97, 98, process.argv.length]); +function load(label, bytes) { + console.log(label, WebAssembly.validate(bytes)); + try { + new WebAssembly.Module(bytes); + } catch (e) { + console.log(label, e instanceof WebAssembly.CompileError, e.message); + } +} +load("simd", simd); +load("invalid", invalid); +load("unseen", unseen); +WebAssembly.compile(simd).catch((e) => console.log("compile", e instanceof WebAssembly.CompileError, e.message)); +`; + +async function run(): Promise { + const key = createHash("sha256") + .update(source) + .update(sanitize ? "san" : "plain") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `wasm-untranslated-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, "untranslated.mjs"); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "untranslated"), + outDir, + sanitize, + backend: "llvm", + }); + if (!result.ok) { + throw new Error( + "program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + return stdout; +} + +describe(`WebAssembly modules without a translation${sanitize ? " (sanitized)" : ""}`, () => { + test("the CompileError names why a module the build found is not translated", async () => { + const lines = (await run()).trimEnd().split("\n"); + const found = (what: string, reason: string) => + `${what}: this module was found when the program was built but not translated (${reason}; see https://scriptc.dev/limitations)`; + const unseen = + "WebAssembly.Module(): this module was not translated when the program was built (scriptc compiles " + + "WebAssembly ahead of time and cannot compile modules whose bytes it did not see; see https://scriptc.dev/limitations)"; + expect(lines[0]).toBe("simd false"); + expect(lines[1]).toBe( + `simd true ${found("WebAssembly.Module()", "it uses simd, outside the translated WebAssembly feature set")}`, + ); + expect(lines[2]).toBe("invalid false"); + expect(lines[3]).toBe( + `invalid true ${found("WebAssembly.Module()", "it is not a valid module: malformed function type (at byte 12)")}`, + ); + expect(lines[4]).toBe("unseen false"); + expect(lines[5]).toBe(`unseen true ${unseen}`); + expect(lines[6]).toBe( + `compile true ${found("WebAssembly.compile()", "it uses simd, outside the translated WebAssembly feature set")}`, + ); + expect(lines).toHaveLength(7); + }, 180_000); +});