diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ab725f0..8116c5d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to scriptc will be documented in this file. +## 0.4.6 + +### Highlights + +- **Node islands:** programs can load Node.js in-process on first use and run npm packages that scriptc doesn't compile in Node islands. + - Native values, closures and promises pass into islands by reference, and island handles, classes and collections can be used from compiled code. + - Modules are shared between a program and its islands, and island modules are evaluated only when the program uses them. + - Once Node loads, the runtime's event loop runs on Node's libuv, and programs exit cleanly with Node loaded. +- **New options:** + - `--exceptions=rare` unwinds through recursive functions instead of checking a status after each call. + - `--static` builds static Linux executables (opt-in). + - `--large-pages` moves executable code onto huge pages; `--cpu-dispatch` adds x86-64-v3 copies of hot functions. + - Type patches replace a package's declaration files or supply missing declarations. + - The shape overlay types JavaScript from facts the compiler proves (opt-in). +- **Faster programs:** + - Exceptions raise through cached unwind plans on x86-64 Linux, and every native function has an unwind table entry. + - Ordinary objects are stored as a layout plus value slots; unions of classes are the instance pointer itself; class instances that stay in their frame use frame storage. + - Object groups that die together are allocated in arenas, and the cycle collector does cheaper trial deletion and adapts its nursery trigger. + - The speed runtime is optimized with a committed runtime profile, and Math functions are computed as V8 does, with libm linked only when needed. + - Loops: short constant-count loops run together, for and while loops are versioned on the ranges their locals enter with, counted loops unroll by the period of a guard, and small leaf functions called from loops are inlined. + - Typed arrays: header fields are read once at a speculative loop's entry, products under bitwise operators stay in 32 bits, and shared typed-array elements are read and written with plain accesses at a counted loop's counter. + - Strings: UTF-16 indexes of long non-ASCII strings map in constant time, ASCII case converts eight bytes at a time, template literals allocate at their exact length, and strings iterate over their bytes. + - Untyped code: class instance fields are read inline at dynamic property sites with two inline-cache entries, native arrays iterate without type-name scans, and numbers pass to directly called untyped functions as JavaScript values. + - I/O: fs thread-pool completions and pool-thread writes make fewer syscalls; sockets, clients and agent entries register in constant time; fetch() reuses keep-alive connections; web streams, WritableStream and TransformStream run natively. + - Large executables map their text with 2 MiB pages, and freed heap pages are returned to the system after the frontend is disposed. +- **Faster builds:** + - Small release and speed programs compile in balanced partitions, the entry module's top-level code gets its own partition, and unchanged partitions stay cached across edits. + - Optimized executables that stay one partition generate code concurrently, and emission's workers stay busy. + - The native compiler is laid out by context-sensitive profiles collected in two phases. + - Type checking uses a shared tsc-ts server by default on Linux and macOS, and lib diagnostics are reused across options and module sets that cannot change them. + - Rebuilds skip the helper version probe and the digests of unchanged files, and the CLI links without huge text pages, so cold builds use less memory. +- **Node.js parity:** + - `Error.captureStackTrace`, Node's navigator, builtin globals read through the global object, and unhandled rejections reported in programs without async work. + - node:zlib is built on the zlib Node bundles; `Response.clone` and `Request.clone` work on typed values; fetch bodies have the members of a web ReadableStream; redirects whose chunked body has not ended are followed. + - Classes that extend Array compile; strings spread into their code points; frozen checked records stay frozen; sloppy-mode `this` is the global object. + - `util.format` runtime format strings, `inspect.custom` and colors, and untyped `promisify`. +- **TypeScript:** scriptc reads TypeScript 7.1's binary AST format and API server beside 7.0's, and tsc-ts is updated to 0.0.10. + + + ## 0.4.5 ### Highlights @@ -40,8 +80,6 @@ All notable changes to scriptc will be documented in this file. - `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node. - **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses. - - ## 0.4.4 ### Highlights diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index 1d4921f48..ad846da82 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index dca236b32..7ecacacd1 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index 1459caa1d..b74fffebe 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 3703c0059..a5a49e9ba 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index 6d70361f9..4cc72ac73 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index 0c12fc60d..14db344e4 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index 8872d2f15..827b76ad1 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 59f9db90a..0ab88f222 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.4.5", + "version": "0.4.6", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/native/ts7-process-child.test.c b/packages/compiler/native/ts7-process-child.test.c index d387f735a..c89fcbafb 100644 --- a/packages/compiler/native/ts7-process-child.test.c +++ b/packages/compiler/native/ts7-process-child.test.c @@ -13,6 +13,7 @@ #include #else #include +#include #include #include #include @@ -102,7 +103,9 @@ static int child_main(int argc, char **argv) { #endif } } - if (strcmp(mode, "echo") != 0) return 73; + // "slow-echo" answers each read 20 ms later: after the client's poll. + int slow = strcmp(mode, "slow-echo") == 0; + if (!slow && strcmp(mode, "echo") != 0) return 73; // Unbuffered reads let short writes make progress without waiting for a // complete stdio buffer. Fragment each echoed reply independently. uint8_t bytes[17]; @@ -113,6 +116,14 @@ static int child_main(int argc, char **argv) { ssize_t count = read(STDIN_FILENO, bytes, sizeof(bytes)); #endif if (count <= 0) return count < 0 ? 74 : 0; + if (slow) { +#ifdef _WIN32 + Sleep(20); +#else + struct timespec delay = {0, 20000000L}; + nanosleep(&delay, NULL); +#endif + } if (fwrite(bytes, 1, (size_t)count, stdout) != (size_t)count || fflush(stdout) != 0) return 75; } } diff --git a/packages/compiler/native/ts7-process.c b/packages/compiler/native/ts7-process.c index bf486c87d..e570fa98c 100644 --- a/packages/compiler/native/ts7-process.c +++ b/packages/compiler/native/ts7-process.c @@ -552,6 +552,41 @@ static int valid_range(const uint8_t *bytes, size_t size, uint32_t offset, uint3 return 1; } +#ifndef _WIN32 +/* How long a read polls the socket before it blocks. The server answers + * most requests within a few microseconds; a blocked reader that the reply + * wakes takes tens of microseconds more per round trip. */ +#define TS7_REPLY_POLL_NS 200000L + +static long monotonic_ns(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return now.tv_sec * 1000000000L + now.tv_nsec; +} + +/* recv on the server's socket: polls it for TS7_REPLY_POLL_NS, then waits. */ +static ssize_t ts7_recv(int socket, uint8_t *bytes, size_t length) { + long started = -1; + for (;;) { + ssize_t count = recv(socket, bytes, length, MSG_DONTWAIT); + if (count >= 0) return count; + if (errno == EINTR) continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) return -1; + long now = monotonic_ns(); + if (started < 0) started = now; + else if (now - started >= TS7_REPLY_POLL_NS) break; +#if defined(__x86_64__) || defined(__i386__) + __builtin_ia32_pause(); +#elif defined(__aarch64__) + __asm__ volatile("yield"); +#endif + } + ssize_t count; + do { count = recv(socket, bytes, length, 0); } while (count < 0 && errno == EINTR); + return count; +} +#endif + int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, uint32_t offset, uint32_t length) { Ts7Process *p = find_process(handle); @@ -565,8 +600,7 @@ int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size, if (code == ERROR_BROKEN_PIPE) return 0; system_error(code); return -1; #else - ssize_t count; - do { count = recv(p->socket, bytes + offset, length, 0); } while (count < 0 && errno == EINTR); + ssize_t count = ts7_recv(p->socket, bytes + offset, length); if (count < 0) { system_error(errno); return -1; } return (int32_t)count; #endif diff --git a/packages/compiler/native/ts7-process.test.c b/packages/compiler/native/ts7-process.test.c index e07a7d10c..c51541772 100644 --- a/packages/compiler/native/ts7-process.test.c +++ b/packages/compiler/native/ts7-process.test.c @@ -149,6 +149,20 @@ static void exchange_cases(void) { scriptc_ts7_close(newer); } +/* A reply that arrives after the read's poll window: the read waits for it. */ +static void slow_reply_cases(void) { + uint32_t id = start("slow-echo", 0); + assert(id != 0); + const uint8_t bytes[3] = {1, 2, 3}; + uint8_t result[3] = {0}; + for (unsigned round = 0; round < 3; round++) { + write_exact(id, bytes, sizeof(bytes), 0, sizeof(bytes)); + read_exact(id, result, sizeof(result)); + assert(memcmp(bytes, result, sizeof(bytes)) == 0); + } + scriptc_ts7_close(id); +} + static void failure_cases(void) { uint8_t bytes[1] = {42}; const char *saved = executable; @@ -289,6 +303,7 @@ static int process_main(int argc, char **argv) { unsigned before = resource_count(); argument_cases(); exchange_cases(); + slow_reply_cases(); failure_cases(); #ifndef _WIN32 attach_cases(); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 3237def0a..a6b52a42f 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.4.5", + "version": "0.4.6", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index 19cff2d5f..c59095a98 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -48,6 +48,8 @@ export async function createNativeLinkPlan(options: { windowsSubsystem?: WindowsSubsystem; profileGenerate?: boolean; profileLayout?: boolean; + /** `false` keeps 4 KiB segment alignment for large program code. */ + hugeText?: boolean; programObjectDependencies?: readonly NativeArtifactDependency[]; env?: NodeJS.ProcessEnv; resolver?: (specifier: string) => string; @@ -55,6 +57,9 @@ export async function createNativeLinkPlan(options: { if (options.ffi?.frameworks?.length && options.target.platform !== "darwin") throw new Error("FFI frameworks require a Darwin target"); const runtimePack = await loadRuntimePack(options); + const hugeText = + options.hugeText !== false && + hugeTextProgram(options.target, [options.programObject, ...(options.programPartitions ?? [])]); return { target: options.target, darwinDebugSymbols: needsDarwinDebugSymbols( @@ -88,11 +93,6 @@ export async function createNativeLinkPlan(options: { programObjectDependencies: [...(options.programObjectDependencies ?? [])], runtimePack, ...(options.staticLink === true ? { staticLink: true } : {}), - ...(hugeTextProgram(options.target, [ - options.programObject, - ...(options.programPartitions ?? []), - ]) - ? { hugeText: true } - : {}), + ...(hugeText ? { hugeText: true } : {}), }; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index f57b70161..e6a72807c 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -96,6 +96,7 @@ import { } from "../../ir/inline-arrays.js"; import { inlineArraysApply, programLifetimeClassOf } from "./inline-arrays.js"; import { pairRowSums } from "../../ir/row-pairs.js"; +import { jamSaturatingLoops } from "../../ir/saturating-loop-jams.js"; import { allocatedLiteral, fillAllocationLength, @@ -763,16 +764,18 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { specializeCallbackCalls( specializeNumericCalls( pairRowSums( - inlineArraysPass( - options, - inlineExpressionFunctions( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + jamSaturatingLoops( + inlineArraysPass( + options, + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), + ), ), ), ), diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index e65a356eb..9e3a5e9b0 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -61,9 +61,11 @@ export function emitUnionWiden( /** The identity union conversion (IR unionRetype). Two tagged unions * share the operand's box: its +1 becomes the result, so no box is built * and no payload is retained. The operand is a whole-value use in every - * lifetime analysis, so it is never a stack box. A nullable-pointer union - * on either side, or a source union whose boxes a checked-dynamic cast has - * cached (and refreshes in place), rebuilds each listed arm instead. */ + * lifetime analysis, so it is never a stack box. Two pointer unions of one + * encoding share the pointer the same way. A pointer union on only one + * side, pointer unions whose encodings differ for an arm the value may + * hold, or a source union whose boxes a checked-dynamic cast has cached (and + * refreshes in place), rebuild each listed arm instead. */ export function emitUnionRetype( host: LlvmEmitterContext, e: IrExpr & { kind: "unionRetype" }, @@ -75,14 +77,15 @@ export function emitUnionRetype( if (from && to) { // Equal encodings make the pointer itself the target value. A shape // union derives its record arms from the record and a class union its - // class arms from the class id, so only the unit encodings must agree. + // class arms from the class id, so only the unit encodings must agree, + // and only when the value may hold one of the source's unit arms: a + // narrowing that drops `undefined` or `null` keeps the very pointer. + const holdsUnit = e.tags.some((tag) => tag === from.unitTag || tag === from.nullTag); + const units = !holdsUnit || (from.unitTag === to.unitTag && from.nullTag === to.nullTag); const same = from.repr === "single" && to.repr === "single" - ? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag - : from.repr !== "single" && - from.repr === to.repr && - from.unitTag === to.unitTag && - from.nullTag === to.nullTag; + ? from.refTag === to.refTag && units + : from.repr !== "single" && from.repr === to.repr && units; if (same) return host.own({ name: value.name, type: e.type }); return host.own({ name: rebuildRetype(host, value.name, e), type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index e2a362bf7..240e1946a 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -11,6 +11,7 @@ import { import type { LlValue } from "./expr-context.js"; import { guardedValue } from "./borrowed-receivers.js"; import { InternalCompilerError } from "../../errors.js"; +import { llvmQuoted } from "../literals.js"; import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js"; import { emitBorrowedMapGet, @@ -459,12 +460,19 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void { } } +/** The alias metadata of the module's field and memory categories. Each type + * node is named by its category (`field:`, `array:header`, ...), not + * by its number: numbers follow the order in which the module's functions + * first use the categories, so an edit that adds one renumbers the rest. The + * LLVM helper renumbers each program partition's metadata, so a partition + * whose functions are unchanged keeps the same text and its cached + * artifacts. */ export function fieldAliasMetadata(emitter: LlEmitter): string[] { if (emitter.fieldAliasTags.size === 0) return []; const lines = ['!0 = !{!"scriptc native fields"}']; - for (const tag of emitter.fieldAliasTags.values()) { + for (const [category, tag] of emitter.fieldAliasTags) { lines.push( - `!${tag - 1} = !{!"field.${tag}", !0, i64 0}`, + `!${tag - 1} = !{!${llvmQuoted(category)}, !0, i64 0}`, `!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`, ); } diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index 502940e71..bc82e7e44 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -268,3 +268,51 @@ test("record unions retype as the record pointer itself", async () => { // No record of the visitor is ever boxed. expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); }); + +// Children that may be absent, as IR nodes have: reading one after its +// `=== undefined` or `=== null` test narrows a shape union with a unit arm +// to the shape union without it. +const OPTIONAL_CHILDREN = ` +type Num = { kind: "num"; value: number }; +type Ref = { kind: "ref"; id: string }; +type Call = { kind: "call"; callee: Expr; receiver?: Expr; args: Expr[] }; +type Init = { kind: "init"; value: Expr | null }; +type Expr = Num | Ref | Call | Init; +function count(e: Expr): number { + switch (e.kind) { + case "num": + case "ref": + return 1; + case "call": + return ( + 1 + + count(e.callee) + + (e.receiver === undefined ? 0 : count(e.receiver)) + + e.args.reduce((n, a) => n + count(a), 0) + ); + case "init": + return 1 + (e.value === null ? 0 : count(e.value)); + } +} +const tree: Expr = { + kind: "call", + callee: { kind: "ref", id: "f" }, + receiver: { kind: "init", value: { kind: "num", value: 1 } }, + args: [{ kind: "init", value: null }], +}; +console.log(count(tree)); +`; + +test("dropping a shape union's unit arm keeps the record pointer", async () => { + const { module, ir } = await llvmFor(OPTIONAL_CHILDREN, false); + const identities = module.functions.filter( + (fn) => + fn.name.startsWith("%union.retag.") && + JSON.stringify(fn.body).includes('"kind":"unionRetype"'), + ); + expect(identities.length).toBeGreaterThan(0); + for (const helper of identities) { + const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`; + expect(body(ir, symbol)).not.toMatch(/retype\.built/); + } +}); diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index a12c137b2..457f96749 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -14,6 +14,7 @@ import { RuntimePackError, type RuntimePackManifest, } from "./runtime-pack.js"; +import { HUGE_TEXT_MIN_CODE_BYTES } from "./huge-text.js"; import { createNativeLinkPlan } from "./link-plan.js"; import { executableLinkerEnvironmentFingerprint, @@ -266,6 +267,34 @@ describe("runtime pack manifests", () => { expect(staticPlan.staticLink).toBe(true); }); + test("Linux links of large program code use huge text unless it is turned off", async () => { + const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET); + // An ELF64 relocatable object's headers with one executable section of + // HUGE_TEXT_MIN_CODE_BYTES; only the headers are read. + const object = Buffer.alloc(64 + 2 * 64); + object.writeUInt32BE(0x7f454c46, 0); + object[4] = 2; + object[5] = 1; + object.writeBigUInt64LE(64n, 40); + object.writeUInt16LE(64, 58); + object.writeUInt16LE(2, 60); + object.writeUInt32LE(1, 128 + 4); // SHT_PROGBITS + object.writeBigUInt64LE(0x6n, 128 + 8); // SHF_ALLOC | SHF_EXECINSTR + object.writeBigUInt64LE(BigInt(HUGE_TEXT_MIN_CODE_BYTES), 128 + 32); + await writeFile(join(root, "program.o"), object); + const options = { + target: LINUX_X64_GNU_TARGET, + programObject: join(root, "program.o"), + outPath: join(root, "program"), + features: BASE, + ffi: null, + optimization: "release" as const, + resolver: () => packagePath, + }; + expect((await createNativeLinkPlan(options)).hugeText).toBe(true); + expect((await createNativeLinkPlan({ ...options, hugeText: false })).hugeText).toBeUndefined(); + }); + test("selection chooses the most-specific variant and feature archive", async () => { const { packagePath } = await fixture(); const resolver = () => packagePath; diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 5a382d9c6..404648051 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -57,6 +57,12 @@ export interface CompileBaseOptions { * at startup when the system allows them. The moved code stays resident. * Executables only; no effect on other platforms. */ largePages?: boolean; + /** Internal: `false` links a Linux executable with 4 KiB segment alignment + * even when its program code is large enough for text on huge pages + * (backend/huge-text.ts). The scriptc CLI is linked this way + * (scripts/build-native-cli.mts): most of its runs are short, and a short + * run maps every 2 MiB text window it touches whole. */ + hugeText?: false; /** How often the program throws (`--exceptions`). `rare` puts recursive * functions and those near a `throw` on the unwinding exception ABI too: * calls to them need no pending-flag test, and an exception that crosses diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 91fa7d791..a6dcc69d8 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -172,6 +172,8 @@ export interface EarlyExecutableCacheOptions { libnodeIdentity?: () => string | undefined; /** Omitted unless --large-pages is set. */ largePages?: true; + /** Omitted unless the build links without huge text (CompileOptions.hugeText). */ + noHugeText?: true; /** Omitted unless --exceptions=rare is set. */ exceptions?: "rare"; /** Omitted for the x86-64 baseline (`--target-cpu`). */ @@ -343,6 +345,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), ...(options.largePages ? ["large-pages"] : []), + ...(options.noHugeText ? ["no-huge-text"] : []), ...(options.exceptions === "rare" ? ["exceptions-rare"] : []), ...targetCpuKeyParts(options.targetCpu), ...cpuDispatchKeyParts(options.cpuDispatch), @@ -393,6 +396,7 @@ function routeKey(options: Omit` or + * `Set` (TypeScript's `any` type arguments of an untyped empty + * collection, mapped to unknown) replaced by a map or set of a concrete + * layout, and with nothing else different. A union `declared` matches when + * `actual` is one of its record arms and every other arm is the residue + * form of that arm. */ +export function anyResidueReplacedBy(lowerer: Lowerer, declared: IrType, actual: IrType): boolean { + const residue = (t: IrType): boolean => + (t.kind === "map" && t.key.kind === "dyn" && t.value.kind === "dyn") || + (t.kind === "set" && t.elem.kind === "dyn"); + const replaces = (d: IrType, a: IrType, seen: Set): boolean => { + if (typeKey(d) === typeKey(a)) return true; + if (residue(d)) return a.kind === d.kind && !residue(a); + if (d.kind === "record" && a.kind === "record") { + const pair = `${d.shapeId}>${a.shapeId}`; + if (seen.has(pair)) return true; + seen.add(pair); + const from = lowerer.shapes.get(d.shapeId); + const to = lowerer.shapes.get(a.shapeId); + if (!from || !to || from.tuple || to.tuple || from.indexValue || to.indexValue) return false; + if (from.fields.length !== to.fields.length) return false; + return from.fields.every((field) => { + const other = to.fields.find((f) => f.name === field.name); + return other !== undefined && replaces(field.type, other.type, seen); + }); + } + return false; + }; + if (declared.kind === "union" && actual.kind === "record") { + const arms = lowerer.unions.get(declared.unionId)?.arms ?? []; + return ( + arms.some((arm) => typeKey(arm) === typeKey(actual)) && + arms.every( + (arm) => + typeKey(arm) === typeKey(actual) || + (arm.kind === "record" && replaces(arm, actual, new Set())), + ) + ); + } + return replaces(declared, actual, new Set()); +} diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index 21cc36016..c2fae508d 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -76,6 +76,17 @@ export function lowerMapMethodCall( if (!probedUntyped && !lowerer.isStdlibMember(access) && name !== "has") return null; const loc = locOf(call); const value = lowerer.lowerExpr(access.expression); + // A receiver the checker types `Map` (inference residue of an + // untyped `new Map()`) whose value has a concrete layout: its keys and + // values convert into that layout. + if ( + value.type.kind === "map" && + receiverIr.key.kind === "dyn" && + receiverIr.value.kind === "dyn" && + value.type.key.kind !== "dyn" && + value.type.value.kind !== "dyn" + ) + receiverIr = value.type; const receiver = lowerer.runtimeOptionalPropertyReceiver(access.expression, value, receiverIr, name) ?? value; if (receiver.type.kind !== "map") { diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index e960bbf6b..421d703d1 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -7,6 +7,8 @@ * * - a union element to one of its arms, or to a union of some of its * arms, by its tag; + * - a union element to a record with the same fields as one of its arms, + * through that arm; * - a checked-dynamic element by dynCheck; * - a class instance to a subclass by its class; * - a tuple or record to one with the same fields, field by field. @@ -241,10 +243,35 @@ function unionConversion( if (targetArms === null) return null; const body: IrStmt[] = []; let kept = 0; + let recordArmConversions = 0; for (let tag = 0; tag < source.arms.length; tag++) { const arm = source.arms[tag]!; const targetTag = targetArms.findIndex((t) => typeEquals(t, arm)); - if (targetTag < 0) continue; + if (targetTag < 0) { + // A record target that is no arm itself but the same fields as one + // (`Model<"a">` from the `Model` arm, whose nested union field + // the predicate narrows): that arm converts field by field. + if (to.kind !== "record" || arm.kind !== "record") continue; + const narrowedArm: IrExpr = { + kind: "unionNarrow", + unionId, + tag, + value: input, + type: arm, + loc, + }; + const converted = conversionBody(lowerer, narrowedArm, arm, to, typeText, loc); + if (converted === null) continue; + recordArmConversions++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: converted, + else_: null, + loc, + }); + continue; + } kept++; const payload: IrExpr = isUnitType(arm) ? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc } @@ -261,10 +288,14 @@ function unionConversion( loc, }); } - // Every arm the target names must be one of the union's own arms. - if (kept === 0 && to.kind !== "union") + // Every arm the target names must be one of the union's own arms, or a + // record target converts from at least one arm. A record target that is + // itself an arm keeps that arm's elements as they are, and the arms with + // its fields convert. Any other structural target that is no arm converts + // from each arm it can be read from. + if (kept === 0 && recordArmConversions === 0 && to.kind !== "union") return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc); - if (kept === 0 || kept !== targetArms.length) return null; + if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null; body.push( typeError( `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index d4034ba8e..cb0d1a89e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -354,6 +354,31 @@ function literalDefault(node: ts.Expression): boolean { ); } +/** A fresh object literal (parentheses allowed) one of whose property + * values, at any literal depth, is `new Map()` or `new Set()` of the + * standard library with neither arguments nor type arguments. */ +function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boolean { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isObjectLiteralExpression(value)) return false; + return value.properties.nodes.some((property) => { + if (!ts.isPropertyAssignment(property)) return false; + let inner = property.initializer; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if (ts.isObjectLiteralExpression(inner)) return untypedCollectionFallback(lowerer, inner); + if ( + !ts.isNewExpression(inner) || + !ts.isIdentifier(inner.expression) || + (inner.arguments?.nodes.length ?? 0) !== 0 || + (inner.typeArguments?.nodes.length ?? 0) !== 0 + ) + return false; + const symbol = lowerer.resolveValueSymbol(inner.expression); + const name = symbol ? ts.symbolName(symbol) : ""; + return symbol !== null && lowerer.isStdlibSymbol(symbol) && (name === "Map" || name === "Set"); + }); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -387,6 +412,24 @@ export function lowerNullishPair( // arm that has no first-class union representation. const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; + // `optionalRecord ?? { a: 1, items: new Map() }`: a fresh fallback literal + // whose untyped empty collections (`new Map()`, `new Set()` without type + // arguments, which the checker types `Map`) leave the checker's + // join as a second record arm. When the literal is assignable to the + // left's record, it builds as that record, so the collections take its + // element types and the result keeps one layout. + if ( + rest.length === 1 && + rest[0]!.kind === "record" && + untypedCollectionFallback(lowerer, expr.right) && + lowerer.checker.isTypeAssignableTo( + lowerer.typeOf(expr.right), + lowerer.checker.getNonNullableType(lowerer.typeOf(expr.left)), + ) + ) { + const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); + return { kind: "nullish", left, right, type: rest[0]!, loc }; + } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations // call the left side present. Preserve a differently typed primitive diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index c692742a8..7378f26a7 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -154,6 +154,22 @@ function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): return open; } +/** `type` when it is the ambient Promise, or its one Promise arm when it is + * a union with exactly one; undefined for anything else (`void`, `unknown`, + * a PromiseLike). */ +function promiseArm(lowerer: Lowerer, type: ts.Type): ts.Type | undefined { + const isPromise = (t: ts.Type): boolean => { + const symbol = t.getSymbol(); + return ( + symbol !== undefined && ts.symbolName(symbol) === "Promise" && lowerer.isStdlibSymbol(symbol) + ); + }; + if (isPromise(type)) return type; + if (!type.isUnionType()) return undefined; + const arms = ts.constituentTypes(type).filter(isPromise); + return arms.length === 1 ? arms[0] : undefined; +} + /** Map a declaration's return contract while retaining JavaScript * inference fallbacks. Diagnostics preserve the caller's blame node. */ export function declaredReturnType( @@ -164,7 +180,39 @@ export function declaredReturnType( if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && ts.isGetAccessorDeclaration(decl)) return DYN; const sig = lowerer.checker.getSignatureFromDeclaration(decl); if (!sig) lowerer.unsupported("SC1090", decl, "this function form"); - const retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + let retTsType = lowerer.checker.getReturnTypeOfSignature(sig); + // An ASYNC object-literal method with no return annotation and a + // contextual signature takes its return from that signature, as an async + // arrow does: the body's `return` builds at the contextually typed + // literal, so the checker's own inferred literal (narrower than the + // contextual record) could never hold it. + if ( + ts.isMethodDeclaration(decl) && + ts.isObjectLiteralExpression(decl.parent) && + decl.type === undefined && + decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true + ) { + const contextual = lowerer.checker.getContextualType(decl.parent); + const slot = + contextual === undefined || (!ts.isIdentifier(decl.name) && !ts.isStringLiteral(decl.name)) + ? undefined + : lowerer.checker.getPropertyOfType( + lowerer.checker.getNonNullableType(contextual), + decl.name.text, + ); + const signatures = + slot === undefined + ? [] + : lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot)); + // A slot whose return is not a promise (`() => void`, or a union such + // as `Promise | void`) contributes only its promise: an async + // method returns one whatever the slot says. + const promise = + signatures.length === 1 + ? promiseArm(lowerer, lowerer.checker.getReturnTypeOfSignature(signatures[0]!)) + : undefined; + if (promise !== undefined) retTsType = promise; + } // An inferred null/undefined return in JS can read a mutable checked-value // field. Keep the actual value instead of restricting it to the initializer. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 487f221c8..f7e514ce2 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -133,7 +133,11 @@ import { isAssertClassNew } from "./assert-instances.js"; import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { initializedCollectionType } from "./js-collection-types.js"; -import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; +import { + lowerMapSeedNew, + lowerSetSeedNew, + mapFromSeedValue, +} from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js"; import { lowerSearchParamsNew } from "./builtins/url.js"; @@ -11085,6 +11089,35 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = ctxMap; } } + // `new Map(source)` the checker types `Map` because the + // source's checker type joins an untyped `new Map()` with a typed map + // (a union of map types): the copy takes the layout the source value + // actually has. + if ( + seedArg && + !entriesLit && + mapped?.kind === "map" && + mapped.key.kind === "dyn" && + mapped.value.kind === "dyn" && + (expr.typeArguments?.nodes.length ?? 0) === 0 && + !isJsSourceFile(ts.getSourceFileOfNode(expr)) && + lowerer.mapTypeOf(lowerer.typeOf(seedArg))?.kind === "union" + ) { + const source = lowerer.lowerExpr(seedArg); + if ( + source.type.kind === "map" && + source.type.key.kind !== "dyn" && + source.type.value.kind !== "dyn" + ) { + const copied = mapFromSeedValue(lowerer, source, source.type); + if (copied) return copied; + } + lowerer.noLowering( + "new Map(entries)", + expr, + "a source whose map layouts differ (give the empty `new Map()` its type arguments)", + ); + } if (seedArg && !entriesLit && mapped?.kind === "map") { const seeded = lowerMapSeedNew(lowerer, seedArg, mapped); if (seeded) return seeded; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 734025215..377b08917 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { anyResidueReplacedBy } from "./any-collection-residue.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -6469,6 +6470,19 @@ export function lowerVarDecl( typeInvolvesWebStream(lowerer, lowerer.typeOf(decl.name)))) ) type = DYN; + // An unannotated TypeScript binding whose checker type carries `any` + // inference residue (`Map` from an untyped `new Map()` joined + // into the initializer's type) takes the initializer's own layout when + // that layout replaces only the residue: `any` admits every use, and the + // value has that layout. + if ( + !decl.type && + type !== null && + !isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !typeEquals(type, init.type) && + anyResidueReplacedBy(lowerer, type, init.type) + ) + type = init.type; // A const whose initializer has another record or array layout than the // binding's type keeps the initializer's layout: converting would copy the // value, so the binding would not be the value its initializer names. diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b837b74fd..3a7850611 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -633,6 +633,7 @@ async function compileExecutableNative( * (BuildSources): the spelling clang sees and native cache keys name. */ llvmVisiblePath?: string, profileLayout = false, + hugeText = true, ): Promise { const programIsObject = /\.(?:o|obj)$/.test(llvmPath); const runtimePackTarget = programIsObject && !sanitize ? nativeCodegenTarget() : null; @@ -650,6 +651,7 @@ async function compileExecutableNative( ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(profileGenerate ? { profileGenerate: true } : {}), ...(profileLayout ? { profileLayout: true } : {}), + ...(hugeText ? {} : { hugeText: false }), programObjectDependencies, }); const cacheableLinker = @@ -1823,6 +1825,7 @@ async function compileTracked( // identity (node-islands/libnode.ts) and a hit requires it unchanged. libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)), ...(opts.largePages === true ? { largePages: true as const } : {}), + ...(opts.hugeText === false ? { noHugeText: true as const } : {}), ...(opts.exceptions === "rare" ? { exceptions: "rare" as const } : {}), ...targetCpuField(opts.targetCpu), ...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}), @@ -2003,6 +2006,7 @@ async function compileTracked( profile?.generate !== undefined, hitSources.llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); @@ -2151,6 +2155,7 @@ async function compileTracked( profile?.generate !== undefined, llvmPath, nativeProfileLayout(profile), + opts.hugeText !== false, ); timing("native-link"); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { diff --git a/packages/compiler/src/ir/saturating-loop-jams.test.ts b/packages/compiler/src/ir/saturating-loop-jams.test.ts new file mode 100644 index 000000000..e25b17e80 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.test.ts @@ -0,0 +1,220 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { everyStmtList } from "./traverse.js"; +import { jamSaturatingLoops } from "./saturating-loop-jams.js"; + +const loc = { file: "saturating-loop-jams.ts", start: 0, end: 0 }; +const BYTES = { kind: "bytes", elem: "f64" } as const; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +type Op = "+" | "*" | "-" | "<" | ">" | "|" | "===" | "<<"; +const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "bin", + op, + left, + right, + type: op === "<" || op === ">" || op === "===" ? BOOL : F64, +}); +const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init }); +const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value }); +const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by))); +const loop = ( + id: string, + end: IrExpr, + by: number, + body: IrStmt[], + start: IrExpr = num(0), +): IrStmt & { kind: "for" } => ({ + loc, + kind: "for", + init: decl(id, start), + cond: bin("<", ref(id), end), + update: stepBy(id, by), + body, +}); +const exitWhen = (acc: string, full: number): IrStmt => ({ + loc, + kind: "if", + cond: bin("===", ref(acc), num(full)), + then: [{ loc, kind: "break" }], + else_: null, +}); +const get = (index: IrExpr): IrExpr => ({ + loc, + kind: "bytesIntrinsic", + method: "get", + receiver: { loc, kind: "varRef", localId: "cr", type: BYTES }, + args: [index], + type: F64, + invalidNaN: true, +}); + +/** One lane of mandelbrot's escape test: `z = z * z + c`, `b |= bit` and + * an exit once `b` is full. */ +function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []): IrStmt[] { + return [ + decl(`${z}.t`, bin("*", ref(z), ref(z))), + set(z, bin("+", ref(`${z}.t`), ref(c))), + { + loc, + kind: "if", + cond: bin(">", ref(`${z}.t`), num(4)), + then: [set("b", bin("|", ref("b"), num(bit))), exitWhen("b", full), ...extra], + else_: null, + }, + ]; +} + +function nest( + options: { + full?: number; + post?: IrStmt[]; + preExtra?: IrStmt[]; + innerExtra?: IrStmt[]; + innerStart?: IrExpr; + innerEnd?: IrExpr; + copies?: number; + } = {}, +): IrStmt[] { + const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))]; + return [ + decl("res", num(0)), + loop("i", num(2 * (options.copies ?? 4)), 2, [ + decl("c1", get(ref("i"))), + decl("c2", get(bin("+", ref("i"), num(1)))), + decl("z1", ref("c1")), + decl("z2", ref("c2")), + decl("b", num(0)), + ...(options.preExtra ?? []), + loop( + "j", + options.innerEnd ?? num(50), + 1, + [ + ...lane("z1", "c1", 2, options.full), + ...lane("z2", "c2", 1, options.full), + ...(options.innerExtra ?? []), + ], + options.innerStart, + ), + ...post, + ]), + ]; +} + +function moduleOf(body: IrStmt[]): IrModule { + const ids = new Set(); + everyStmtList(body, { + expr: (e) => { + if (e.kind === "varRef" && e.localId !== "cr") ids.add(e.localId); + return true; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") ids.add(s.localId); + return true; + }, + }); + const fn: IrFunction = { + name: "render", + params: [{ localId: "cr", name: "cr", type: BYTES }], + locals: [ + { id: "cr", name: "cr", type: BYTES, mutable: false }, + ...[...ids].map((id) => ({ id, name: id, type: F64, mutable: true })), + ], + body, + returnType: VOID, + loc, + }; + return { irVersion: 17, sourceFile: loc.file, entry: "render", functions: [fn] } as IrModule; +} + +function loops(body: IrStmt[]): (IrStmt & { kind: "for" })[] { + const found: (IrStmt & { kind: "for" })[] = []; + everyStmtList(body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "for") found.push(s); + return true; + }, + }); + return found; +} + +test("jams a constant-count loop whose inner loop exits once its accumulator is full", () => { + const mod = moduleOf(nest()); + const out = jamSaturatingLoops(mod); + expect(out).not.toBe(mod); + const body = out.functions[0]!.body; + // The outer loop is gone: one inner loop runs the four iterations. + const remaining = loops(body); + expect(remaining.length).toBe(1); + const inner = remaining[0]!; + // Its only exit tests every copy's accumulator, at the end of the body. + let breaks = 0; + everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => { + if (s.kind === "break") breaks++; + return true; + }, + }); + expect(breaks).toBe(1); + const last = inner.body[inner.body.length - 1]!; + expect(last.kind).toBe("if"); + // Each copy keeps its own accumulator and lanes; `post` runs in order. + const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam.")); + expect(added.filter((local) => local.name === "%jam.b").length).toBe(4); + const block = body[1]!; + expect(block.kind).toBe("block"); + if (block.kind !== "block") return; + const posts = block.body.slice(-4); + expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true); +}); + +test("keeps loops whose exits or values do not allow running iterations together", () => { + const reject = (body: IrStmt[]): void => { + const mod = moduleOf(body); + expect(jamSaturatingLoops(mod)).toBe(mod); + }; + // An exit before the accumulator is full: later ORs would change it. + reject(nest({ full: 2 })); + // `post` reads a lane value the copies keep computing past their exit. + reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] })); + // `pre` reads what `post` writes. + reject(nest({ preExtra: [decl("seed", ref("res"))] })); + // The inner loop writes the accumulator other than by an OR: a later + // iteration can clear bits after an exit. + reject(nest({ innerExtra: [set("b", num(0))] })); + reject(nest({ innerExtra: [set("b", bin("+", ref("b"), num(4)))] })); + // The inner loop's body advances its counter, which the copies share. + reject(nest({ innerExtra: [stepBy("j", 1)] })); + // The inner loop's start or bound reads the counter or a local of the + // iteration. + reject(nest({ innerStart: ref("i") })); + reject(nest({ innerEnd: bin("+", num(10), ref("i")) })); + reject(nest({ preExtra: [decl("start", num(3))], innerStart: ref("start") })); + // One iteration, or more than eight. + reject(nest({ copies: 1 })); + reject(nest({ copies: 9 })); + // A store in the inner loop is observable past an exit. + reject([ + decl("res", num(0)), + loop("i", num(8), 2, [ + decl("b", num(0)), + decl("z1", num(0)), + loop("j", num(50), 1, [ + ...lane("z1", "z1", 2, 3, [ + { + loc, + kind: "bytesSet", + arr: { loc, kind: "varRef", localId: "cr", type: BYTES }, + index: num(0), + value: num(1), + }, + ]), + ]), + set("res", bin("|", ref("res"), ref("b"))), + ]), + ]); +}); diff --git a/packages/compiler/src/ir/saturating-loop-jams.ts b/packages/compiler/src/ir/saturating-loop-jams.ts new file mode 100644 index 000000000..96973a483 --- /dev/null +++ b/packages/compiler/src/ir/saturating-loop-jams.ts @@ -0,0 +1,443 @@ +import { + BOOL, + F64, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { everyStmtList, transformStmtList } from "./traverse.js"; + +/** The most iterations of an outer loop that run jammed. */ +const MAX_COPIES = 8; + +/** The most statement and expression nodes of one outer iteration: every + * copy duplicates it. */ +const MAX_BODY_NODES = 160; + +/** Binary operators of numbers and booleans that run no other code and + * cannot throw. */ +const PURE_BINARY = new Set([ + "+", + "-", + "*", + "/", + "%", + "&", + "|", + "^", + "<<", + ">>", + ">>>", + "<", + "<=", + ">", + ">=", + "===", + "!==", +]); + +const PURE_UNARY = new Set(["-", "+", "~", "!"]); + +interface Jam { + counter: string; + /** The counter's value on each iteration. */ + values: number[]; + pre: IrStmt[]; + inner: IrStmt & { kind: "for" }; + post: IrStmt[]; + /** The accumulator the inner loop's exits test, and its full value. */ + acc: string; + full: number; +} + +function isRef(e: IrExpr | null | undefined, id: string): boolean { + return e?.kind === "varRef" && e.localId === id; +} + +function intLit(e: IrExpr | null | undefined): number | null { + return e?.kind === "numLit" && Number.isSafeInteger(e.value) && !Object.is(e.value, -0) + ? e.value + : null; +} + +/** `id = id + step` with a positive integer literal step. */ +function constantStep(update: IrStmt | null, id: string): number | null { + if ( + update?.kind !== "assign" || + update.localId !== id || + update.value.kind !== "bin" || + update.value.op !== "+" || + !isRef(update.value.left, id) + ) + return null; + const step = intLit(update.value.right); + return step !== null && step > 0 ? step : null; +} + +/** A number or boolean expression that runs no other code and cannot throw: + * literals, reads of locals and module bindings, arithmetic, comparisons + * and typed-array element reads that answer NaN out of range. */ +function pureExpr(e: IrExpr, locals: ReadonlyMap): boolean { + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + switch (e.kind) { + case "numLit": + case "boolLit": + return true; + case "varRef": { + const local = locals.get(e.localId); + if (local) return !local.boxed && !local.tdz; + return e.localId.startsWith("%g."); + } + case "bin": + return PURE_BINARY.has(e.op) && pureExpr(e.left, locals) && pureExpr(e.right, locals); + case "unary": + return PURE_UNARY.has(e.op) && pureExpr(e.operand, locals); + case "bytesIntrinsic": + return ( + e.method === "get" && + e.invalidNaN === true && + e.receiver.kind === "varRef" && + e.args.length === 1 && + pureExpr(e.args[0]!, locals) + ); + default: + return false; + } +} + +/** A local written by `s` (a declaration or assignment), or null. */ +function writtenLocal(s: IrStmt): string | null { + return s.kind === "varDecl" || s.kind === "assign" ? s.localId : null; +} + +/** Straight-line declarations and assignments of pure values. */ +function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap): boolean { + return list.every( + (s) => + (s.kind === "varDecl" && s.init !== null && pureExpr(s.init, locals)) || + (s.kind === "assign" && locals.has(s.localId) && pureExpr(s.value, locals)), + ); +} + +/** The bits `C` of `id = id | C`, with `C` a non-negative int32 literal, or + * null. */ +function orBits(s: IrStmt): number | null { + if ( + s.kind !== "assign" || + s.value.kind !== "bin" || + s.value.op !== "|" || + !isRef(s.value.left, s.localId) + ) + return null; + const bits = intLit(s.value.right); + return bits !== null && bits >= 0 && bits <= 0x7fffffff ? bits : null; +} + +/** The statements of the inner loop's body: pure declarations and + * assignments, `if` statements over them, `acc = acc | C`, and + * `if (acc === full) break` (collected into `breaks`). */ +function innerStatements( + list: readonly IrStmt[], + locals: ReadonlyMap, + acc: { id: string | null; bits: number; breaks: number[] }, +): boolean { + return list.every((s) => { + if (s.kind === "if") { + // `if (acc === full) break` + if ( + s.then.length === 1 && + s.then[0]!.kind === "break" && + s.then[0]!.label === undefined && + (s.else_ === null || s.else_.length === 0) && + s.cond.kind === "bin" && + s.cond.op === "===" && + s.cond.left.kind === "varRef" && + intLit(s.cond.right) !== null + ) { + if (acc.id !== null && acc.id !== s.cond.left.localId) return false; + acc.id = s.cond.left.localId; + acc.breaks.push(intLit(s.cond.right)!); + return true; + } + return ( + pureExpr(s.cond, locals) && + innerStatements(s.then, locals, acc) && + innerStatements(s.else_ ?? [], locals, acc) + ); + } + const bits = orBits(s); + if (s.kind === "assign" && bits !== null) { + if (acc.id !== null && acc.id !== s.localId) return false; + acc.id = s.localId; + acc.bits |= bits; + return true; + } + return pureStatements([s], locals); + }); +} + +function countNodes(list: readonly IrStmt[]): number { + let n = 0; + everyStmtList(list as IrStmt[], { + expr: () => ++n <= MAX_BODY_NODES, + stmt: () => ++n <= MAX_BODY_NODES, + }); + return n; +} + +/** Reads of local `id` anywhere in `list`. */ +function reads(list: readonly IrStmt[], id: string): boolean { + return !everyStmtList(list as IrStmt[], { expr: (e) => !isRef(e, id), stmt: () => true }); +} + +/** Locals written anywhere in `list`. */ +function writes(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + const id = writtenLocal(s); + if (id !== null) out.add(id); + return true; + }, + }); + return out; +} + +/** Locals declared anywhere in `list`. */ +function declared(list: readonly IrStmt[]): Set { + const out = new Set(); + everyStmtList(list as IrStmt[], { + expr: () => true, + stmt: (s) => { + if (s.kind === "varDecl") out.add(s.localId); + return true; + }, + }); + return out; +} + +/** The jam of `loop`, or null. */ +function matchJam( + loop: IrStmt & { kind: "for" }, + locals: ReadonlyMap, +): Jam | null { + if (loop.labels?.length || loop.init?.kind !== "varDecl") return null; + const counter = loop.init.localId; + const local = locals.get(counter); + if (local?.type.kind !== "f64" || local.boxed || local.tdz) return null; + const start = intLit(loop.init.init); + const step = constantStep(loop.update, counter); + const cond = loop.cond; + if ( + start === null || + step === null || + cond?.kind !== "bin" || + cond.op !== "<" || + !isRef(cond.left, counter) + ) + return null; + const end = intLit(cond.right); + if (end === null) return null; + const values: number[] = []; + for (let v = start; v < end && values.length <= MAX_COPIES; v += step) values.push(v); + if (values.length < 2 || values.length > MAX_COPIES) return null; + // One inner loop, with straight-line code before and after it. + const at = loop.body.findIndex((s) => s.kind === "for"); + if (at < 0) return null; + const inner = loop.body[at] as IrStmt & { kind: "for" }; + const pre = loop.body.slice(0, at), + post = loop.body.slice(at + 1); + if (!pureStatements(pre, locals) || !pureStatements(post, locals)) return null; + if (countNodes(loop.body) > MAX_BODY_NODES) return null; + if (inner.labels?.length || inner.init?.kind !== "varDecl") return null; + const j = inner.init.localId; + const jLocal = locals.get(j); + if (jLocal?.type.kind !== "f64" || jLocal.boxed || jLocal.tdz) return null; + if (inner.init.init === null || !pureExpr(inner.init.init, locals)) return null; + if (inner.cond === null || !pureExpr(inner.cond, locals) || constantStep(inner.update, j) !== 1) + return null; + const acc = { id: null as string | null, bits: 0, breaks: [] as number[] }; + if (!innerStatements(inner.body, locals, acc) || acc.id === null) return null; + const full = acc.bits; + // Every exit tests the accumulator against the value every OR leaves + // unchanged once it is reached. + if (acc.breaks.length === 0 || acc.breaks.some((value) => value !== full)) return null; + const accId = acc.id; + // The accumulator starts at a literal in `pre`, and the inner loop changes + // it only by its ORs. + const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId); + if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null; + const onlyOrs = everyStmtList(inner.body, { + expr: () => true, + stmt: (s) => writtenLocal(s) !== accId || orBits(s) !== null, + }); + if (!onlyOrs) return null; + // Every copy shares the inner counter: only the inner loop's update writes + // it. + if (writes(inner.body).has(j)) return null; + // The counter, the inner bound and every value the inner loop reads from + // outside the iteration are the same for every copy: only `pre` and the + // counter vary. + const own = declared([...pre, inner]); + own.delete(counter); + const innerWrites = writes([inner]); + innerWrites.delete(j); + for (const id of innerWrites) if (!own.has(id) && id !== accId) return null; + if (writes([inner]).has(counter) || writes(pre).has(counter) || writes(post).has(counter)) + return null; + // Copies run `pre` and the inner loop of later iterations before `post` + // of earlier ones: `pre` and the inner loop must not read what `post` + // writes, and `pre` writes only its own declarations. + const postWrites = writes(post); + for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null; + for (const id of writes(pre)) if (!own.has(id)) return null; + // After the inner loop, iterations past a copy's exit are dead: `post` + // reads no value the inner loop writes besides the accumulator. + for (const id of innerWrites) if (id !== accId && reads(post, id)) return null; + // The copies share the inner loop's start and bound: they read neither the + // counter nor a local of the iteration. + const controls: IrStmt[] = [ + { kind: "exprStmt", expr: inner.init.init, loc: inner.loc }, + { kind: "exprStmt", expr: inner.cond, loc: inner.loc }, + ]; + if (reads(controls, counter)) return null; + for (const id of own) if (id !== j && reads(controls, id)) return null; + return { counter, values, pre, inner, post, acc: accId, full }; +} + +/** Runs the iterations of a short constant-count loop together when its body + * is an inner loop that exits once an accumulator reaches the value its ORs + * saturate at (mandelbrot's pairs of pixels per output byte): + * + * for (let i = 0; i < 8; i += 2) { + * + * for (let j = 0; j < M; j++) { + * ; if (c) { b |= 2; if (b === 3) break; } ... + * } + * + * } + * + * becomes every iteration's `pre` (with `i` a literal), one inner loop + * running every iteration's body in turn that exits when every accumulator + * is full, and every iteration's `post` in order. Once an accumulator is + * full its ORs leave it full, so the iterations a copy runs past its own + * exit leave its accumulator as its own loop left it, and every other value + * they compute is dead: the bodies only compute numbers, read typed-array + * elements that answer NaN out of range, and write their own locals. `pre` + * and the inner loop read nothing `post` writes, so running later `pre`s + * and inner loops first changes no value. Independent iterations then + * overlap their dependence chains. */ +export function jamSaturatingLoops(mod: IrModule): IrModule { + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (fn.async || fn.generator) return fn; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + let found = false; + everyStmtList(fn.body, { + expr: () => !found, + stmt: (s) => { + if (s.kind === "for" && matchJam(s, locals) !== null) found = true; + return !found; + }, + }); + if (!found) return fn; + const added: IrLocal[] = []; + const ids = new Set(fn.locals.map((local) => local.id)); + const fresh = (of: IrLocal, copy: number): string => { + let id = `%jam.${copy}.${of.id}`; + while (ids.has(id)) id += "_"; + ids.add(id); + const { source: _source, ...rest } = of; + added.push({ ...rest, id, name: `%jam.${of.name}` }); + return id; + }; + const body = transformStmtList(fn.body, { + expr: (e) => e, + stmt: (s) => { + if (s.kind !== "for") return s; + const jam = matchJam(s, locals); + return jam === null ? s : emitJam(jam, locals, fresh); + }, + }); + changed = true; + return { ...fn, locals: [...fn.locals, ...added], body }; + }); + return changed ? { ...mod, functions } : mod; +} + +function emitJam( + jam: Jam, + locals: ReadonlyMap, + fresh: (of: IrLocal, copy: number) => string, +): IrStmt { + const { inner, pre, post, values, counter } = jam; + const loc = inner.loc; + const own = declared([...pre, ...inner.body]); + const j = (inner.init as IrStmt & { kind: "varDecl" }).localId; + own.delete(j); + const copies = values.map((value, copy) => { + const rename = new Map(); + for (const id of own) rename.set(id, fresh(locals.get(id)!, copy)); + const rewrite = (list: readonly IrStmt[]): IrStmt[] => + transformStmtList(list as IrStmt[], { + expr: (e) => { + if (e.kind !== "varRef") return e; + if (e.localId === counter) return { kind: "numLit", value, type: F64, loc: e.loc }; + const to = rename.get(e.localId); + return to === undefined ? e : { ...e, localId: to }; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign") { + const to = rename.get(s.localId); + if (to !== undefined) return { ...s, localId: to }; + } + return s; + }, + }); + // The copy's exits become the shared test at the end of the iteration. + const withoutBreaks = (list: readonly IrStmt[]): IrStmt[] => + list.flatMap((s): IrStmt[] => { + if (s.kind !== "if") return [s]; + if (s.then.length === 1 && s.then[0]!.kind === "break") return []; + return [ + { ...s, then: withoutBreaks(s.then), else_: s.else_ ? withoutBreaks(s.else_) : null }, + ]; + }); + return { + pre: rewrite(pre), + body: rewrite(withoutBreaks(inner.body)), + post: rewrite(post), + acc: rename.get(jam.acc)!, + }; + }); + let allFull: IrExpr | null = null; + for (const copy of copies) { + const full: IrExpr = { + kind: "bin", + op: "===", + left: { kind: "varRef", localId: copy.acc, type: F64, loc }, + right: { kind: "numLit", value: jam.full, type: F64, loc }, + type: BOOL, + loc, + }; + allFull = + allFull === null + ? full + : { kind: "logical", op: "&&", left: allFull, right: full, type: BOOL, loc }; + } + const jammed: IrStmt = { + ...inner, + body: [ + ...copies.flatMap((copy) => copy.body), + { kind: "if", cond: allFull!, then: [{ kind: "break", loc }], else_: null, loc }, + ], + }; + return { + kind: "block", + body: [...copies.flatMap((copy) => copy.pre), jammed, ...copies.flatMap((copy) => copy.post)], + loc, + }; +} diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..7139fed28 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "compilerVersion": "0.4.5", + "compilerVersion": "0.4.6", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", diff --git a/packages/compiler/test/alias-metadata-names.test.ts b/packages/compiler/test/alias-metadata-names.test.ts new file mode 100644 index 000000000..3674ce50b --- /dev/null +++ b/packages/compiler/test/alias-metadata-names.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +async function emit(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-alias-names-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return emitLlvmModule(deserializeModule(await readFile(output, "utf8"))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The alias type names that a function's tagged loads and stores use, in order. */ +function aliasNames(llvm: string, symbol: string): string[] { + const found = new RegExp(`^define [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm); + expect(found, symbol).not.toBeNull(); + const node = (id: string) => new RegExp(`^!${id} = !\\{(.*)\\}$`, "m").exec(llvm)?.[1] ?? ""; + return [...found![0].matchAll(/!tbaa !(\d+)/g)].map((tag) => { + const type = /^!(\d+),/.exec(node(tag[1]!))?.[1] ?? ""; + return /^!"([^"]*)"/.exec(node(type))?.[1] ?? ""; + }); +} + +const points = ` +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} +function sum(points: Point[]): number { + let total = 0; + for (const p of points) total += p.x * p.y; + return total; +} +`; + +test("alias type nodes are named by category, so a field added earlier leaves the others' names", async () => { + const before = await emit( + `${points}const all = [new Point(1, 2), new Point(3, 4)];\nconsole.log(sum(all));\n`, + ); + // A class whose field the module uses before Point's. + const after = await emit(` +class Weight { + grams: number; + constructor(grams: number) { + this.grams = grams; + } +} +function heaviest(weights: Weight[]): number { + let most = 0; + for (const w of weights) most = Math.max(most, w.grams); + return most; +} +console.log(heaviest([new Weight(5), new Weight(7)])); +${points}const all = [new Point(1, 2), new Point(3, 4)]; +console.log(sum(all)); +`); + const names = aliasNames(before, "sc_b?f_sum"); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name !== "" && !/^field\.\d+$/.test(name))).toBe(true); + expect(aliasNames(after, "sc_b?f_sum")).toEqual(names); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json new file mode 100644 index 000000000..0d9124d48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6870-node-island-server-fibers/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6870-node-island-server-fibers/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..5338193d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-object-method-contextual-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-object-method-contextual-return.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..c74a7d818 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/filter-predicate-model-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/filter-predicate-model-narrow.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..364970fee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-untyped-empty-fallback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/map-untyped-empty-fallback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..b44d5a127 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-before-effectful-key.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/receiver-before-effectful-key.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json new file mode 100644 index 000000000..34cd32574 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/saturating-loop-jams.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/saturating-loop-jams.ts" + ], + "diags": [] +} diff --git a/packages/experimental/package.json b/packages/experimental/package.json index 74e213448..55004c082 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/experimental", - "version": "0.4.5", + "version": "0.4.6", "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, per-thread slots on shared objects, and array fields kept in their instances", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 7bd8c4bbc..593e2e76a 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -195,6 +195,26 @@ bool TakeFunction(Isolate *isolate, Local context, Local from, return true; } +// OpenSSL registers OPENSSL_cleanup with atexit on its first +// initialization. In a scriptc program that loaded Node, threads Node owns +// can still be inside OpenSSL when the process's exit handlers run: the +// threads node:tls starts to parse the root certificates (long with +// NODE_USE_SYSTEM_CA=1; RunLoop waits for them, but an exit that does not +// end the loop does not) are joined only by Node's per-process teardown, +// which cannot run here (see Stop). OPENSSL_cleanup +// freed OpenSSL's locks and method stores under them (SIGSEGV/SIGABRT at +// exit in up to half the runs, NI-7c). Initializing OpenSSL first with +// OPENSSL_INIT_NO_ATEXIT keeps that handler from being registered: OpenSSL's +// process-lifetime state is left to process exit, like the rest of Node's +// per-process state. Packs whose libnode does not export the symbol keep +// OpenSSL's default. +void KeepOpenSSLAtExit() { + using InitCrypto = int (*)(uint64_t, const void *); + constexpr uint64_t kNoAtExit = 0x00080000L; // OPENSSL_INIT_NO_ATEXIT (OpenSSL 3) + auto init = reinterpret_cast(dlsym(RTLD_DEFAULT, "OPENSSL_init_crypto")); + if (init != nullptr) init(kNoAtExit, nullptr); +} + uint32_t ExportIdOf(Isolate *isolate, Local context, Local v); // isExport(value): whether a value is the representative of a native value @@ -214,6 +234,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar Dl_info info; if (dladdr(reinterpret_cast(&napi_create_function), &info) && info.dli_fname) dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD | RTLD_GLOBAL); + KeepOpenSSLAtExit(); std::vector args = {argv0}; // The program is not a node process: NODE_OPTIONS from its environment // does not apply, and Node leaves the process's resource limits and code @@ -433,10 +454,18 @@ void OnCap(uv_timer_t *) { SyncIo(SCR_NODE_IO_JOBS); } -void OnPrepare(uv_prepare_t *) { SyncIo(SCR_NODE_IO_PREPARE); } +// Before each poll phase, while the runtime has live work: one pass over +// its dispatch stations, as the runtime's own loop makes one per turn. +// Some of their work no fd announces (a server's deferred 'listening' or +// 'close', a socket's queued writes), so waiting for an fd could block the +// loop forever with that work pending. +void OnPrepare(uv_prepare_t *handle) { + if (uv_has_ref(reinterpret_cast(handle))) Dispatch(); + SyncIo(SCR_NODE_IO_PREPARE); +} // The end of an iteration: the next one may dispatch again (from its idle -// phase when work is ready, else from its poll phase). +// or prepare phase, else from its poll phase). void OnCheck(uv_check_t *) { g_dispatched = false; SyncIo(SCR_NODE_IO_CHECK); @@ -616,6 +645,29 @@ void Checkpoint(int microtasks_first) { node::CallbackScope scope(e.isolate, Object::New(e.isolate), node::async_context{0, 0}); } +// Loading node:tls starts threads that parse the root certificates into +// function-local statics (bundled, NODE_EXTRA_CA_CERTS, and the system +// store with NODE_USE_SYSTEM_CA=1, which takes long). Only Node's +// CleanupCachedRootCertificates joins them. Once exit() begins, the +// statics' destructors (registered when the threads initialize them, so +// they run before any handler the program registered earlier) free the +// vectors under a thread that is still parsing, and OpenSSL's state goes +// away under it: heap corruption and SIGSEGV/SIGABRT at exit (NI-7c). So +// when the loop is done, before the program returns, the bridge waits for +// them: building a root store reads the same statics under the same +// conditions, and initializing a static blocks until the thread that runs +// it is done. A store built earlier means the statics were initialized +// then. Programs that never loaded node:tls do nothing here. +void AwaitCertificateLoading(Enter &e) { + TryCatch try_catch(e.isolate); + static const char kSource[] = + "(() => { try { if (process.moduleLoadList.includes('NativeModule tls'))" + " process.getBuiltinModule('node:tls').createSecureContext(); } catch {} })()"; + Local script; + if (v8::Script::Compile(e.context, Utf8(e.isolate, kSource)).ToLocal(&script)) + (void)script->Run(e.context); +} + int RunLoop(int continue_turn) { Enter e; // The rest of the turn the switch interrupted: libuv's UV_RUN_ONCE skips @@ -624,7 +676,9 @@ int RunLoop(int continue_turn) { uv_run(g_setup->event_loop(), UV_RUN_ONCE); g_platform->DrainTasks(e.isolate); } - return node::SpinEventLoop(g_setup->env()).FromMaybe(0); + int code = node::SpinEventLoop(g_setup->env()).FromMaybe(0); + AwaitCertificateLoading(e); + return code; } // An island listener of a process event: through MakeCallback, so a throw @@ -653,9 +707,16 @@ void Stop() { delete g_locker; g_locker = nullptr; g_setup.reset(); + // As Node's own exit does (DefaultProcessExitHandlerInternal): join + // libuv's thread pool, whose work (pbkdf2, key generation, ...) may be + // inside OpenSSL, before the process's exit handlers clean OpenSSL up. + uv_library_shutdown(); v8::V8::Dispose(); v8::V8::DisposePlatform(); - node::TearDownOncePerProcess(); + // Node's per-process teardown is left to process exit: Stop runs from an + // exit handler registered when Node started, after the destructors of + // the certificate statics (registered later) already ran, so its + // certificate cleanup would free them a second time. } diff --git a/packages/libnode-darwin-arm64/package.json b/packages/libnode-darwin-arm64/package.json index 22d1e1889..9e9113a68 100644 --- a/packages/libnode-darwin-arm64/package.json +++ b/packages/libnode-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS arm64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-darwin-x64/package.json b/packages/libnode-darwin-x64/package.json index dc9f3e06f..9a1e43bc6 100644 --- a/packages/libnode-darwin-x64/package.json +++ b/packages/libnode-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS x64", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-arm64-gnu/package.json b/packages/libnode-linux-arm64-gnu/package.json index 108e179cd..1ba37111f 100644 --- a/packages/libnode-linux-arm64-gnu/package.json +++ b/packages/libnode-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux arm64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/libnode-linux-x64-gnu/package.json b/packages/libnode-linux-x64-gnu/package.json index af2c3bace..e361aa369 100644 --- a/packages/libnode-linux-x64-gnu/package.json +++ b/packages/libnode-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/libnode-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "private": true, "description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux x64 glibc", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index d1ac54f3e..78198ec25 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index b96a90cae..1a9ed9d2c 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 49796f522..c72277e4a 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index 3001949c6..a9489efdd 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index 11b375ba4..6949973db 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index ffe1ca7ae..d1ab5309a 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index 77e6fe687..9d41b3df1 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/node/package.json b/packages/node/package.json index 6a080da99..bb82fd4d8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/node", - "version": "0.4.5", + "version": "0.4.6", "description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index 7aea1da1e..405d2e8b1 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index ae068a00a..06e7033d3 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index 7dbdaf6b9..02ed6b3af 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index 8a868a606..17af72e9e 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 011d41b39..807ef48c8 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 92a3192e7..a00679bab 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 5e751d829..c7f91ba98 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 63c045385..edfd142c6 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index a8602b395..e1e1d6955 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index 799863cf0..4aad3dbd4 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 421f1f71f..8409e7d25 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.4.5", + "version": "0.4.6", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 90140ab15..97a5b3344 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.4.5", + "version": "0.4.6", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0225269f4..8f540d3d6 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1701,9 +1701,26 @@ static void *scr_win_self(void) { } #endif +#ifdef SCR_NODE_ISLANDS +/* The lowest frame of Node's stack that is still live while a fiber runs: + * where runtime code on Node's stack (a job or host callback, however deep + * it went) last switched to a fiber. A call into Node from that fiber runs + * on Node's stack below it (scr_node_island.c), never over those frames. + * 0: none. scr_node_stack_has (set when Node starts) tells Node's stack. */ +uintptr_t scr_node_stack_low = 0; +bool (*scr_node_stack_has)(uintptr_t sp) = NULL; +#endif + static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #ifdef SCR_ASAN_FIBERS ScrFiber *from_fiber = scr_current; +#endif +#ifdef SCR_NODE_ISLANDS + uintptr_t node_low = scr_node_stack_low; + if (to_fiber != NULL && scr_node_stack_has != NULL) { + uintptr_t here = (uintptr_t)__builtin_frame_address(0); + if (scr_node_stack_has(here)) scr_node_stack_low = here; + } #endif scr_current = to_fiber; scr_exc_swap_cell(to_fiber ? &to_fiber->exc : NULL); @@ -1756,8 +1773,59 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #if !defined(_WIN32) && !defined(__wasi__) SCR_STACK_GUARD_SET(guard); #endif +#ifdef SCR_NODE_ISLANDS + scr_node_stack_low = node_low; /* back on the stack that switched away */ +#endif } +#ifdef SCR_NODE_ISLANDS +/* A nested entry into Node (scr_node_island.c) runs on Node's stack in a + * context of its own while the fiber that made the call stays suspended in + * it. Runtime code that Node calls back there runs as the loop's code does: + * no current fiber, the main exception cell and ALS slot. It may resume + * other fibers, which saves the resumer's context in scr_loop_ctx (and, + * under ASan, its fake stack in the main slot); those belong to whoever + * resumed the calling fiber, so they are saved here and put back when the + * nested entry returns, or the calling fiber's next park would return to a + * context that no longer exists. */ +typedef struct { + ScrFiber *current; + ScrAlsCtx **als; + ScrCtx loop_ctx; +#ifdef SCR_ASAN_FIBERS + void *main_fake; +#endif +} ScrNestedSave; + +void *scr_loop_nested_enter(void) { + ScrNestedSave *s = malloc(sizeof *s); + if (s == NULL) scr_oom(); + s->current = scr_current; + s->als = scr_als_active; + s->loop_ctx = scr_loop_ctx; +#ifdef SCR_ASAN_FIBERS + s->main_fake = scr_main_fake_stack; + scr_main_fake_stack = NULL; +#endif + scr_current = NULL; + scr_exc_swap_cell(NULL); + scr_als_active = &scr_als_main_slot; + return s; +} + +void scr_loop_nested_leave(void *save) { + ScrNestedSave *s = save; + scr_loop_ctx = s->loop_ctx; +#ifdef SCR_ASAN_FIBERS + scr_main_fake_stack = s->main_fake; +#endif + scr_current = s->current; + scr_exc_swap_cell(s->current ? &s->current->exc : NULL); + scr_als_active = s->als; + free(s); +} +#endif + static void scr_promise_settle_wake(ScrPromise *p); /* Copies src's settlement into a still-pending dst (payload RETAINED — @@ -3423,20 +3491,25 @@ ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) { * platform mutex publishes each result before the loop removes it from the * completion queue. */ #if !defined(SCR_LIB) && !defined(__wasi__) -/* The completion wake: a worker signals it once per published completion, +/* The completion wake: a worker signals it when it publishes a completion, * so the loop's sleep ends for the completion — a pipe the poll(2) sleep - * watches on POSIX, an auto-reset event the Windows sleep waits on. */ + * watches on POSIX, an auto-reset event the Windows sleep waits on. On + * POSIX, `posted` (guarded by the queue lock) records that the pipe holds + * a byte: a worker writes one only when none is there, and the loop reads + * it when it takes the last completion, so the pipe holds a byte exactly + * while completions wait. */ typedef struct ScrFsRenameWake { #ifdef _WIN32 HANDLE event; /* NULL until created */ #else int fds[2]; /* -1 until created */ + bool posted; #endif } ScrFsRenameWake; #ifdef _WIN32 #define SCR_FS_RENAME_WAKE_NONE {NULL} #else -#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}} +#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}, false} #endif #ifdef SCR_WORKERS typedef struct ScrFsRenameOwner ScrFsRenameOwner; @@ -3530,28 +3603,29 @@ static void scr_fs_rename_wake_open(ScrFsRenameWake *wake) { #endif } +/* Called with the queue lock held. */ static void scr_fs_rename_wake_post(ScrFsRenameWake *wake) { #ifdef _WIN32 if (wake->event != NULL) (void)SetEvent(wake->event); #else - if (wake->fds[1] >= 0) { + if (wake->fds[1] >= 0 && !wake->posted) { char byte = 1; - ssize_t n = write(wake->fds[1], &byte, 1); /* a full pipe already wakes the loop */ - (void)n; + wake->posted = write(wake->fds[1], &byte, 1) == 1; } #endif } -/* POSIX: empty the pipe before taking the queue, so a completion published - * afterwards leaves its byte for the next poll. The Windows event resets - * when a wait consumes it. */ +/* POSIX, with the queue lock held: the loop took the last completion, so + * the pipe's byte is read and the next completion writes a new one. The + * Windows event resets when a wait consumes it. */ static void scr_fs_rename_wake_drain(ScrFsRenameWake *wake) { #ifdef _WIN32 (void)wake; #else - if (wake->fds[0] < 0) return; + if (wake->fds[0] < 0 || !wake->posted) return; + wake->posted = false; char buf[64]; - while (read(wake->fds[0], buf, sizeof buf) > 0) { + while (read(wake->fds[0], buf, sizeof buf) == (ssize_t)sizeof buf) { } #endif } @@ -3661,6 +3735,18 @@ static DWORD WINAPI scr_fs_rename_worker(LPVOID payload) { #else static void *scr_fs_rename_worker(void *payload) { (void)payload; + /* A pool thread keeps SIGPIPE and SIGXFSZ blocked: its file writes then + * report EPIPE and EFBIG without changing the signal mask per write + * (scr_fs_write_once). */ + sigset_t write_set; + sigemptyset(&write_set); +#ifdef SIGPIPE + sigaddset(&write_set, SIGPIPE); +#endif +#ifdef SIGXFSZ + sigaddset(&write_set, SIGXFSZ); +#endif + (void)pthread_sigmask(SIG_BLOCK, &write_set, NULL); scr_fs_rename_worker_loop(); return NULL; } @@ -3759,13 +3845,13 @@ static bool scr_fs_renames_dispatch(void) { #ifdef SCR_WORKERS if (!scr_fs_rename_owner) return false; #endif - scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_enter(); ScrFsRenameOp *op = scr_fs_rename_done; if (op != NULL) { scr_fs_rename_done = op->next; if (scr_fs_rename_done == NULL) scr_fs_rename_done_tail = &scr_fs_rename_done; } + if (scr_fs_rename_done == NULL) scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner)); scr_fs_rename_lock_leave(); if (op == NULL) return false; scr_fs_rename_pending_count--; diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index a7157f9af..02ad86aa1 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -235,6 +235,7 @@ typedef struct { bool falling; bool idle_points; size_t death_due_live; + bool probe_due; bool keep_base; size_t trigger; size_t alloc_trigger; @@ -286,6 +287,7 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_falling (scr_cyc_thread.falling) #define scr_cyc_idle_points (scr_cyc_thread.idle_points) #define scr_cyc_death_due_live (scr_cyc_thread.death_due_live) +#define scr_cyc_probe_due (scr_cyc_thread.probe_due) #define scr_cyc_keep_base (scr_cyc_thread.keep_base) #define scr_cyc_kinds (scr_cyc_thread.kinds) #define scr_cyc_kinds_cap (scr_cyc_thread.kinds_cap) @@ -640,6 +642,10 @@ static SCR_TL bool scr_cyc_falling = false, scr_cyc_keep_base = false; #ifndef SCR_WORKERS static SCR_TL bool scr_cyc_idle_points = false; static SCR_TL size_t scr_cyc_death_due_live = 0; +/* A nursery probe window opened since the last scheduled pass (see "level + * bypass"): the next pass the release path would run at the nursery runs at + * the mature level instead. */ +static SCR_TL bool scr_cyc_probe_due = false; #endif /* The nursery trigger a program sets for itself @@ -976,9 +982,11 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { if (!by_growth && !by_death && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { gen = SCR_CYC_MATURE; by_growth = scr_cyc_grown(SCR_CYC_MATURE); - if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE)) + if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE) && + !scr_cyc_probe_due) gen = SCR_CYC_NURSERY; } + scr_cyc_probe_due = false; scr_cyc_scheduled_pass(gen, by_growth, by_death); } @@ -1059,6 +1067,16 @@ bool scr_cyc_collect_idle(void) { * a probe window every candidate of the type stays at the level, so the * cycles it builds during the window die there. * + * Those young cycles can still be held by older garbage: a dead structure + * the bypass promoted before the window that points at objects built in it. + * A restricted pass reads that edge as external, so the window frees none + * of them, judges the type long-lived again, and the garbage on both sides + * waits for a mature pass, which the growth schedule may not run until the + * heap has grown a multiple of its size. So a nursery window that opens + * makes the next release-triggered pass that would run at the nursery run + * at the mature level: it reclaims the promoted garbage once, and the window + * judges the young cycles on their own. + * * This changes only which generation an object is judged in. Any * generation assignment is sound (a restricted pass treats edges from older * objects as external), and a bypassing candidate is buffered like any @@ -1110,7 +1128,8 @@ static inline ScrCycKind *scr_cyc_kind(uint32_t kind) { /* Whether a candidate of this type at level `gen` (nursery or mature) * skips that level's passes (see above). The last candidate a bypass sends - * past the level opens a probe window, with fresh counts. */ + * past the level opens a probe window, with fresh counts, and a nursery + * window asks for a mature pass first. */ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { uint32_t kind = type->kind; @@ -1119,9 +1138,12 @@ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycT if (k->bypass == 0) return false; if (--k->bypass == 0) { k->walked = k->freed = 0; + if (gen == SCR_CYC_NURSERY) { + scr_cyc_probe_due = true; #if SCR_CYC_INLINE_BUFFERING - if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--; + scr_cyc_nursery_bypassing--; #endif + } } return true; } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ad0c613ee..9ef71a223 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4101,6 +4101,50 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, #ifdef SIGXFSZ sigaddset(&write_set, SIGXFSZ); #endif + /* A thread that keeps both signals blocked (the fs pool's threads) writes + * without changing its mask; a write that fails with EPIPE or EFBIG then + * consumes the signal it left pending. The answer is per OS thread and + * computed once: _Thread_local, not SCR_TL, which is empty in builds + * without thread instances while the pool threads still exist. */ + static _Thread_local signed char thread_blocks = -1; + if (thread_blocks < 0) { + sigset_t current; + bool blocks = pthread_sigmask(SIG_BLOCK, NULL, ¤t) == 0; +#ifdef SIGPIPE + blocks = blocks && sigismember(¤t, SIGPIPE) == 1; +#endif +#ifdef SIGXFSZ + blocks = blocks && sigismember(¤t, SIGXFSZ) == 1; +#endif + thread_blocks = blocks ? 1 : 0; + } + if (thread_blocks == 1) { + ssize_t n = positioned + ? scr_fs_pwrite(fd, data, length, position) + : write(fd, data, length); + if (n < 0) { + int write_errno = errno; + int raised = 0; +#ifdef SIGPIPE + if (write_errno == EPIPE) raised = SIGPIPE; +#endif +#ifdef SIGXFSZ + if (write_errno == EFBIG) raised = SIGXFSZ; +#endif + if (raised != 0 && sigpending(&pending) == 0 && sigismember(&pending, raised) == 1) { + sigset_t raised_set; + sigemptyset(&raised_set); + sigaddset(&raised_set, raised); + int caught = 0; + int wait_err; + do { + wait_err = sigwait(&raised_set, &caught); + } while (wait_err == EINTR); + } + errno = write_errno; + } + return n; + } if (pthread_sigmask(SIG_BLOCK, &write_set, &old_set) != 0) { return positioned ? scr_fs_pwrite(fd, data, length, position) diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index eb00f2bde..b046f0972 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -93,6 +93,25 @@ void __sanitizer_finish_switch_fiber(void *fake_stack_save, const void **bottom_ size_t *size_old); void __lsan_register_root_region(const void *p, size_t size) __attribute__((weak)); void __asan_unpoison_memory_region(void const volatile *addr, size_t size); + +/* LeakSanitizer's report at exit, with Node loaded: Node keeps its root + * certificate stores (the bundled roots, the system's with + * NODE_USE_SYSTEM_CA, the extra ones of NODE_EXTRA_CA_CERTS) for the + * process's life and never frees them, and OpenSSL keeps the compression + * methods it loads with its default library context. Only those loaders + * are suppressed; a leak anywhere else, in libnode or in the program, + * still reports. */ +__attribute__((used)) const char *__lsan_default_suppressions(void) { + return "leak:node::crypto::LoadCACertificates\n" + "leak:node::crypto::GetBundledRootCertificates\n" + "leak:node::crypto::LoadSystemCACertificates\n" + "leak:node::crypto::GetExtraCACertificates\n" + "leak:ossl_load_builtin_compressions\n"; +} + +/* Without the table of suppressions used, which LeakSanitizer otherwise + * prints to stderr at every exit (LSAN_OPTIONS still overrides it). */ +__attribute__((used)) const char *__lsan_default_options(void) { return "print_suppressions=0"; } #endif #endif @@ -954,8 +973,15 @@ static void ni_nested_trampoline(void) { static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ucontext_t nested, caller; char *lo = ni_stack + NI_GUARD; - /* Below the red zone and 16-byte aligned, as makecontext expects. */ - uintptr_t hi = (ni_mark - 512) & ~(uintptr_t)15; + /* Below every live frame of Node's stack: the mark the host callback + * recorded, or lower where runtime code it ran went deeper before it + * switched to this fiber (an async call's eager start, a job: the + * spawner's frame and its saved context are still in use there). Below + * the red zone and the swap's own frame, 16-byte aligned, as makecontext + * expects. */ + uintptr_t top = ni_mark; + if (scr_node_stack_low != 0 && scr_node_stack_low < top) top = scr_node_stack_low; + uintptr_t hi = (top - 1024) & ~(uintptr_t)15; ni_make_context(&nested, lo, hi - (uintptr_t)lo, ni_nested_trampoline); #ifdef NI_ASAN ni_nested_top = hi; @@ -965,6 +991,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { ni_nested_return = &caller; uintptr_t guard = scr_stack_guard; SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)lo, hi - (uintptr_t)lo)); + void *loop_save = scr_loop_nested_enter(); #ifdef NI_ASAN void *caller_fake = NULL; __sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo); @@ -975,6 +1002,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) { size_t size; __sanitizer_finish_switch_fiber(caller_fake, &bottom, &size); #endif + scr_loop_nested_leave(loop_save); SCR_STACK_GUARD_SET(guard); } @@ -994,6 +1022,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); if (ni_stack == MAP_FAILED) ni_oom(); (void)mprotect(ni_stack, NI_GUARD, PROT_NONE); + scr_node_stack_has = &ni_stack_has; #ifdef NI_ASAN if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK); #endif diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 08ee22f8e..0f314c96a 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -43,6 +43,16 @@ ScrPromise *scr_node_island_call(ScrStr *file, ScrStr *specifier, ScrStr *name, struct ScrNodeBridge; struct ScrNodeIoState; void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms); +/* The lowest live frame of Node's stack while a fiber runs (the SCR_NODE_ISLANDS + * build of scr_async.c records it at each switch from Node's stack to a + * fiber; 0: none), and the test for Node's stack it uses. */ +extern uintptr_t scr_node_stack_low; +extern bool (*scr_node_stack_has)(uintptr_t sp); +/* Around a nested entry: the runtime's scheduling state of the suspended + * calling fiber, saved and put back (the SCR_NODE_ISLANDS build of + * scr_async.c). */ +void *scr_loop_nested_enter(void); +void scr_loop_nested_leave(void *save); bool scr_loop_is_hosted(void); void scr_loop_hosted_run(uint32_t kind, uint32_t token); void scr_loop_hosted_io_state(struct ScrNodeIoState *state); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 5b66d2bbc..97c47c2b7 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1424,6 +1424,10 @@ static const ScrDynHandleOps nh_ops = { * own objects. */ static void nh_cleanup(void) { nh_drain_freed(); + /* What the last conversion for the export unit kept alive. */ + NhKeep last = nh_out_keep; + memset(&nh_out_keep, 0, sizeof nh_out_keep); + nh_keep_done(&last); if (nh_inspect_fn != NULL) { scr_dyn_release(nh_inspect_fn); nh_inspect_fn = NULL; diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 961d43586..f18d29ff8 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1039,6 +1039,75 @@ static void check_nursery_bypass(void) { scr_collect_cycles(); } +/* A nursery probe window that opens makes the next release-triggered pass + * that would run at the nursery run at the mature level, which reclaims + * dead promoted rings a nursery pass stops at. */ +static size_t window_kind_freed, window_ring_freed; +static void window_kind_free(void *obj) { + window_kind_freed++; + node_free(obj); +} +static void window_ring_free(void *obj) { + window_ring_freed++; + node_free(obj); +} +static ScrCycType window_kind_type = { &node_trace, &window_kind_free, 9 }; +static ScrCycType window_ring_type = { &node_trace, &window_ring_free, 0 }; + +/* A candidate of the window kind that dies once it has been routed, as the + * next pass would route it: it counts down a bypass without growing the + * heap. */ +static void release_window_candidate(void) { + Node *node = scr_cyc_alloc(sizeof(*node), &window_kind_type); + node->rc = 2; + release_live(node); + scr_cyc_route_bypassed_at(); + node->rc = 0; + scr_cyc_on_dead(node); + window_kind_free(node); +} + +static void check_probe_window_runs_mature_pass(void) { + /* Every release collects at a threshold of 1, and passes with only a few + * candidates walk too few of them to decide a window. */ + if (configured_nursery_threshold() < 8) return; + enum { WINDOW = 8192, INTERVAL = 16, LIVE = WINDOW, RING = 4 }; + Node **live = calloc(LIVE, sizeof(*live)); + if (!live) scr_trap("cycle test: out of memory\n"); + for (size_t i = 0; i < LIVE; i++) { + live[i] = scr_cyc_alloc(sizeof(*live[i]), &window_kind_type); + live[i]->rc = 1; + read_live(live[i]); + scr_cyc_pass_at(SCR_CYC_NURSERY); + } + scr_collect_cycles(); + /* A pass that an earlier window asked for runs now. */ + run_release_passes(); + + /* A dead ring promoted to the mature level: nursery passes stop at it. */ + Node *ring = make_ring_of(RING, &window_ring_type); + ring->rc++; + release_live(ring); + scr_cyc_pass_at(SCR_CYC_NURSERY); + release_live(ring); + run_release_passes(); + check(window_ring_freed == 0, "a nursery pass freed a dead mature ring"); + + /* The window kind's candidates go past the nursery until a probe window + * opens; the next release-triggered pass runs at the mature level. */ + for (size_t i = 0; i < (size_t)WINDOW * INTERVAL; i++) release_window_candidate(); + run_release_passes(); + check(window_ring_freed == RING, "the pass after a probe window opened missed a dead mature ring"); + + for (size_t i = 0; i < LIVE; i++) { + live[i]->rc = 0; + scr_cyc_on_dead(live[i]); + window_kind_free(live[i]); + } + free(live); + scr_collect_cycles(); +} + /* The mature level's bypass: a type whose mature candidates keep surviving * goes straight to the old generation, its dead cycles are found by full * passes, and once its objects die at the mature level again it is judged @@ -1719,6 +1788,7 @@ int main(void) { check_death_brings_full_pass(); check_nursery_bypass(); check_mature_bypass(); + check_probe_window_runs_mature_pass(); check_probe_sees_candidate_rings(); check_rebuffer_past_mature(); check_allocation_growth_runs_passes(); diff --git a/packages/threads/package.json b/packages/threads/package.json index 6ebcf5b8b..56811f3e4 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.4.5", + "version": "0.4.6", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/types/package.json b/packages/types/package.json index b2aa805be..547161cb9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/types", - "version": "0.4.5", + "version": "0.4.6", "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 6a9334e4a..6c17b829b 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -164,6 +164,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"), ...(profileGenerate === undefined ? {} : { profileGenerate }), ...(profileUse === undefined ? {} : { profileUse }), ...(functionLayout === undefined ? {} : { functionLayout }), + // Most of the compiler's runs are short (an unchanged rebuild takes tens of + // milliseconds), and a run maps every 2 MiB text window it touches whole. Text + // on 4 KiB pages keeps an unchanged rebuild at about a third of the peak RSS, + // and builds of tsc-ts take the same time. Executables the compiler builds + // keep huge text. + hugeText: false, emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1", }); if (!result.ok) diff --git a/scripts/refresh-cli-layout.mjs b/scripts/refresh-cli-layout.mjs index cb396015f..4b7437a0d 100644 --- a/scripts/refresh-cli-layout.mjs +++ b/scripts/refresh-cli-layout.mjs @@ -92,9 +92,11 @@ const scriptc = join(cli, "bin/scriptc"); const baseEnv = { ...process.env, SCRIPTC_TIMING: "1" }; delete baseEnv["SCRIPTC_LLVM_HELPER"]; -/** Runs the instrumented compiler, writing its profile to `options.profile`. */ +/** Runs the instrumented compiler, writing its profile to `options.profile`. + * Throws when the profile runtime reports that it could not write the + * profile (a full disk, for example): the compiler still exits with 0. */ function run(args, options) { - return new Promise((done) => { + return new Promise((done, fail) => { const child = spawn(scriptc, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], @@ -109,7 +111,9 @@ function run(args, options) { child.stderr.on("data", (chunk) => (text += chunk)); child.on("close", (code) => { appendFileSync(log, `$ scriptc ${args.join(" ")}\n${text}exit ${code}\n`); - done({ ok: code === 0, text }); + const profileError = /^LLVM Profile Error: .*$/m.exec(text)?.[0]; + if (profileError !== undefined) fail(new Error(`scriptc ${args.join(" ")}: ${profileError}`)); + else done({ ok: code === 0, text }); }); }); } @@ -142,8 +146,24 @@ const training = [ // rebuild that missed a cache (it shows no `frontend-cache-hit` or // `executable-cache-hit` phase) is left out. The first builds each write // their own profile (cold--*), merged across the build's processes. +// Each profile is read and deleted when its build finishes: a profile is +// about 23 MB, and keeping every build's until the end takes about 9 GB. const warm = new Set(); let skipped = 0; +// For each function, how many of the first builds ran it. +const runs = new Map(); +let coldBuilds = 0; +function countColdRun(index) { + const profiles = readdirSync(raw).filter((name) => name.startsWith(`cold-${index}-`)); + if (profiles.length === 0) return; + const ran = new Set(); + for (const name of profiles) { + for (const fn of executedFunctions(join(raw, name))) ran.add(fn); + rmSync(join(raw, name)); + } + for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); + coldBuilds++; +} async function warmRun(args, cache, name, cacheHits) { const profile = join(raw, `warm-${name}.profraw`); const { ok, text } = await run(args, { cwd: root, cache, profile }); @@ -166,6 +186,7 @@ const passed = await pool( cache, profile: join(raw, `cold-${index}-%1m.profraw`), }); + countColdRun(index); if (ok) await warmRun(args, cache, String(index), ["frontend-cache-hit", "executable-cache-hit"]); rmSync(cache, { recursive: true, force: true }); @@ -196,6 +217,12 @@ function executedFunctions(path) { const dataStart = 16 * 8 + binaryIdsBytes; const countersStart = dataStart + records * recordBytes + beforeCounters; const namesStart = countersStart + counters * 8 + afterCounters + bitmapBytes + afterBitmap; + // The profile runtime reports a failed write (a full disk, for example) on + // stderr, and the compiler still exits with 0, leaving a partial file. + if (buf.length < namesStart + namesBytes) + throw new Error( + `${path} is ${buf.length} bytes, shorter than the ${namesStart + namesBytes} bytes its header describes`, + ); const countersDelta = buf.readBigInt64LE(10 * 8); const names = new Map(); let at = namesStart; @@ -241,18 +268,8 @@ function executedFunctions(path) { // tier 2 for functions at least 90% of the builds run, tier 3 for 60%, and so // on; tier 7 holds the functions fewer than 3% of the builds run. const COLD_TIERS = [0.9, 0.6, 0.3, 0.1, 0.03]; -const coldRuns = new Map(); -for (const name of readdirSync(raw)) { - const program = /^cold-(\d+)-/.exec(name)?.[1]; - if (program === undefined) continue; - const ran = coldRuns.get(program) ?? new Set(); - for (const fn of executedFunctions(join(raw, name))) ran.add(fn); - coldRuns.set(program, ran); -} -const runs = new Map(); -for (const ran of coldRuns.values()) for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1); const coldTier = (count) => { - const share = count / coldRuns.size; + const share = count / coldBuilds; const index = COLD_TIERS.findIndex((least) => share >= least); return 2 + (index < 0 ? COLD_TIERS.length : index); }; @@ -269,7 +286,7 @@ const perTier = new Map(); for (const line of lines) perTier.set(line.split(" ")[0], (perTier.get(line.split(" ")[0]) ?? 0) + 1); console.log( - `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldRuns.size} builds (${statSync(output).size} bytes)`, + `wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldBuilds} builds (${statSync(output).size} bytes)`, ); rmSync(builds, { recursive: true, force: true }); if (values.work === undefined) rmSync(work, { recursive: true, force: true }); diff --git a/tests/corpus/6870-node-island-server-fibers/main.ts b/tests/corpus/6870-node-island-server-fibers/main.ts new file mode 100644 index 000000000..829f796c1 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/main.ts @@ -0,0 +1,91 @@ +// @requires-libnode +// An HTTP server whose async request handler awaits island promises, calls +// island functions, extends an island class and arms timers, while an +// island client drives it (a long-running service's shape): the handler's +// continuations run as runtime jobs that Node's loop +// dispatches, possibly on an async fiber's stack, and every entry into Node +// from there must run on Node's stack, where V8 scans it during the +// collections the island's allocations cause. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { churn, delay, depth, get, Handler } from "server-kit"; + +class CountingHandler extends Handler { + served = 0; + async handle(path: string): Promise { + this.served++; + return `${await super.handle(path)}#${this.served}`; + } +} + +const handler = new CountingHandler("kit"); +const timers: string[] = []; + +// Called from a continuation (a runtime job on Node's stack): its eager +// start runs on a fiber and calls into the island, deep, before its first +// await returns to the caller's frame on Node's stack. +async function listFiles(path: string, wait: (n: number) => Promise): Promise { + const out: string[] = []; + try { + for (let i = 0; i < 3; i++) { + out.push(`${path}:${depth(300 + i)}`); + } + const more = await wait(out.length); + out.push(String(more)); + } finally { + out.push("done"); + } + return out; +} + +// Starts listFiles a few native frames deeper on the current stack. +function startDeep(levels: number, path: string): Promise { + const parts = [path, String(levels), path.length.toString(16)]; + if (levels > 0) return startDeep(levels - 1, parts[0]!); + return listFiles(parts.join("").slice(0, path.length), (n) => delay(1, n)); +} + +async function respond(path: string): Promise { + const waited = await delay(1, path); + const files = (await startDeep(24, waited)) as string[]; + let total = 0; + for (let i = 0; i < 3; i++) { + total += churn(i) > 0 ? 1 : 0; + await delay(0); + } + const handled = await handler.handle(waited); + await new Promise((resolve) => + setTimeout(() => { + timers.push(path); + resolve(); + }, 1), + ); + return `${handled} churn=${total} files=${files.length}`; +} + +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const path = req.url ?? "/"; + respond(path).then( + (text) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(text); + }, + (error: unknown) => { + res.writeHead(500); + res.end(String(error)); + }, + ); +}); + +async function main(): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + for (let i = 0; i < 40; i++) { + const reply = await get(port, `/r${i}`); + if (i % 10 === 0 || reply.status !== 200) console.log(reply.status, reply.body); + } + console.log("timers", timers.length, "served", handler.served, "count", handler.count); + server.close(() => console.log("closed")); +} + +void main(); diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts new file mode 100644 index 000000000..d4ae40527 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.d.ts @@ -0,0 +1,14 @@ +export interface Reply { + status: number; + body: string; +} +export declare function get(port: number, path: string): Promise; +export declare function delay(ms: number, value?: T): Promise; +export declare function churn(seed: number): number; +export declare function depth(n: number): number; +export declare class Handler { + name: string; + count: number; + constructor(name: string); + handle(path: string): Promise; +} diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js new file mode 100644 index 000000000..31ba6ed3b --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/index.js @@ -0,0 +1,53 @@ +"use strict"; +// An island package shaped like a service's HTTP helpers: a +// client that requests over HTTP and answers promises, a base class that +// native code extends, and work that allocates enough to collect garbage. +const http = require("node:http"); + +function get(port, path) { + return new Promise((resolve, reject) => { + http + .get({ host: "127.0.0.1", port, path, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body })); + }) + .on("error", reject); + }); +} + +function delay(ms, value) { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} + +// Allocates and drops a few megabytes, so collections run while native +// callers are on the stack. +function churn(seed) { + let keep = 0; + for (let i = 0; i < 2000; i++) { + const a = new Array(64).fill(seed + i); + keep += a.length + JSON.stringify({ i, a: a.slice(0, 4) }).length; + } + return keep; +} + +// Recursion deep enough to use tens of kilobytes of the stack it runs on. +function depth(n) { + const pad = [n, n + 1, n + 2, n + 3]; + return n <= 0 ? pad.length : depth(n - 1) + (pad[n % 4] > 0 ? 1 : 0); +} + +class Handler { + constructor(name) { + this.name = name; + this.count = 0; + } + async handle(path) { + this.count++; + await delay(1); + return `${this.name}:${path}`; + } +} + +module.exports = { get, delay, churn, depth, Handler }; diff --git a/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json new file mode 100644 index 000000000..234acb589 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/node_modules/server-kit/package.json @@ -0,0 +1 @@ +{ "name": "server-kit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6870-node-island-server-fibers/package.json b/tests/corpus/6870-node-island-server-fibers/package.json new file mode 100644 index 000000000..26859cb33 --- /dev/null +++ b/tests/corpus/6870-node-island-server-fibers/package.json @@ -0,0 +1 @@ +{ "name": "island-server-fibers", "private": true, "type": "module", "scriptc": { "packages": { "server-kit": "node" } } } diff --git a/tests/corpus/async-object-method-contextual-return.ts b/tests/corpus/async-object-method-contextual-return.ts new file mode 100644 index 000000000..6c3a93758 --- /dev/null +++ b/tests/corpus/async-object-method-contextual-return.ts @@ -0,0 +1,40 @@ +type Text = { type: "text"; text: string; textSignature?: string }; +type Image = { type: "image"; data: string; mimeType: string }; +interface ToolResult { content: (Text | Image)[]; details: D; isError?: boolean } +interface ToolDefinition { name: string; execute(id: string, q: string): Promise> } +function define(): ToolDefinition<{ loaded: string[] }> { + return { + name: "search", + async execute(_id, q) { + const text = `found ${q}`; + return { content: [{ type: "text", text }], details: { loaded: [q] } }; + }, + }; +} +define().execute("1", "x").then((r) => console.log(JSON.stringify(r))); + +// Slots whose return is not a promise: an async method there still returns +// its own promise, and a union slot contributes only its promise arm. +interface Hooks { + start(): void; + stop(): Promise | void; + load(q: string): Promise<{ n: number; q: string }> | undefined; +} +const hooks: Hooks = { + async start() { + console.log("start"); + }, + async stop() { + console.log("stop"); + }, + async load(q) { + return { n: q.length, q }; + }, +}; +async function run(): Promise { + await hooks.start(); + await hooks.stop(); + const loaded = await hooks.load("abc"); + console.log(loaded?.n, loaded?.q); +} +run(); diff --git a/tests/corpus/filter-predicate-model-narrow.ts b/tests/corpus/filter-predicate-model-narrow.ts new file mode 100644 index 000000000..c881fbdef --- /dev/null +++ b/tests/corpus/filter-predicate-model-narrow.ts @@ -0,0 +1,54 @@ +// `.filter` with a written type predicate whose target is no arm of the +// element union but the same fields as one, with a nested union field the +// predicate narrows (`Model<"a">` from the `Model` arm). Kept elements +// convert field by field; a kept element of another shape would throw. + +interface CompatA { + strict?: boolean; +} +interface CompatB { + effort?: string; +} +interface CompatMap { + a: CompatA; + b: CompatB; +} +type Api = keyof CompatMap; +interface Model { + kind: "chat"; + api: A; + id: string; + window: number; + compat?: CompatMap[A]; +} +interface Image { + kind: "image"; + id: string; +} +type AnyModel = Model | Image; + +function restore(stored: AnyModel[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} + +const stored: AnyModel[] = [ + { kind: "chat", api: "a", id: "1", window: 8, compat: { strict: true } }, + { kind: "image", id: "2" }, + { kind: "chat", api: "b", id: "3", window: 4, compat: { effort: "hi" } }, + { kind: "chat", api: "a", id: "4", window: 2 }, +]; +const restored = restore(stored); +for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model); +console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0)); + +// A target that is itself an arm of the union keeps that arm's elements as +// they are, while another arm with its fields converts. +type Mixed = Model<"a"> | Model | Image; +function pick(stored: Mixed[]): Model<"a">[] { + return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a"); +} +const exact: Model<"a"> = { kind: "chat", api: "a", id: "5", window: 3, compat: { strict: false } }; +const picked = pick([exact, ...stored]); +console.log(picked.map((model) => `${model.id}:${model.window}:${model.compat?.strict}`).join(" "), picked[0] === exact); + +export {}; diff --git a/tests/corpus/map-untyped-empty-fallback.ts b/tests/corpus/map-untyped-empty-fallback.ts new file mode 100644 index 000000000..e9506dac1 --- /dev/null +++ b/tests/corpus/map-untyped-empty-fallback.ts @@ -0,0 +1,41 @@ +// An untyped `new Map()` in a `??` fallback literal: the fallback builds +// as the left operand's record, so the empty map takes its key and value +// types. `new Map(result.errors)` then copies a typed map, and writes to +// the copy (directly and from a callback) keep that layout. + +interface RefreshResult { + aborted: boolean; + errors: ReadonlyMap; +} + +async function refresh(fail: boolean): Promise { + if (fail) return undefined; + const errors = new Map(); + errors.set("openai", new Error("boom")); + return { aborted: false, errors }; +} + +async function run(fail: boolean, providers: string[]): Promise<{ aborted: boolean; errors: Map }> { + const result = (await refresh(fail)) ?? { aborted: fail, errors: new Map() }; + const errors = new Map(result.errors); + errors.set("local", new Error("extra")); + await Promise.all( + providers.map(async (provider) => { + if (provider.startsWith("bad")) errors.set(provider, new Error(`${provider} failed`)); + }), + ); + return { aborted: result.aborted, errors }; +} + +async function main(): Promise { + for (const fail of [false, true]) { + const r = await run(fail, ["ok", "bad-a", "bad-b"]); + const names = [...r.errors.keys()].sort().join(","); + console.log(r.aborted, names, r.errors.get("local")?.message, r.errors.get("bad-b")?.message); + } + const source = new Map([["a", 1]]); + const copy = new Map(source); + copy.set("b", 2); + console.log(source.size, copy.size, copy !== source); +} +void main(); diff --git a/tests/corpus/receiver-before-effectful-key.ts b/tests/corpus/receiver-before-effectful-key.ts new file mode 100644 index 000000000..496c9c4c3 --- /dev/null +++ b/tests/corpus/receiver-before-effectful-key.ts @@ -0,0 +1,59 @@ +// A receiver is evaluated once, before its key, even when the key's +// evaluation rebinds the receiver's binding or has other effects: a tuple +// read with a runtime index, a keyed read through a finite union of field +// names, and an inherited-key `in` test on a computed index-signature +// receiver. + +const log: string[] = []; + +// Tuple read with a runtime index that rebinds the tuple. +let pair: [string, number] = ["first", 1]; +function rebindPair(i: number): number { + log.push(`rebind pair ${i}`); + pair = ["second", 2]; + return i; +} +const viaIndex = pair[rebindPair(0)]; +console.log("tuple", viaIndex, pair[0]); + +// A field holding a tuple, replaced by the index. +const holder: { t: [number, number, number] } = { t: [10, 20, 30] }; +function replaceHeld(i: number): number { + holder.t = [-1, -2, -3]; + return i; +} +console.log("tuple field", holder.t[replaceHeld(2)], holder.t[2]); + +// Keyed read through a finite union of keys naming a subset of the fields, +// with a key that rebinds the local or replaces the field holding the record. +type Row = { width?: number; label: string; tags: string[] }; +function finiteKeys(): void { + let row: Row = { width: 640, label: "old", tags: [] }; + const pick = (): "width" | "label" => { + log.push("pick key"); + row = { width: 1, label: "new", tags: ["x"] }; + return "label"; + }; + const picked = row[pick()]; + console.log("keyed", picked, row.label); + const box: { row: Row } = { row: { label: "boxed", tags: [] } }; + const replace = (): "width" | "label" => { + box.row = { width: 7, label: "replaced", tags: [] }; + return "width"; + }; + const width = box.row[replace()]; + console.log("keyed field", width, box.row.width); +} +finiteKeys(); + +// `in` on an inherited key of a computed null-prototype record: the +// receiver call runs once. +let groups = 0; +function grouped(): Partial> { + groups++; + log.push("grouped"); + return Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); +} +console.log("in", "toString" in grouped(), "hasOwnProperty" in grouped(), groups); + +console.log(log.join(" | ")); diff --git a/tests/corpus/saturating-loop-jams.ts b/tests/corpus/saturating-loop-jams.ts new file mode 100644 index 000000000..fb0fd5bb3 --- /dev/null +++ b/tests/corpus/saturating-loop-jams.ts @@ -0,0 +1,164 @@ +// Escape-time loops in the shape of mandelbrot's rows: a short constant-count +// loop over pixel pairs whose inner loop exits once both pixels of the pair +// have escaped (`b |= 2`, `b |= 1`, `if (b === 3) break`). Pixels escape at +// different iterations or never; each byte packs the pairs' bits in order. +const LIMIT = 4; + +function row(initial: Float64Array, ci: number, bytes: number, iterations: number): number[] { + const out: number[] = []; + for (let xByte = 0; xByte < bytes; xByte++) { + let res = 0; + for (let i = 0; i < 8; i += 2) { + const x = xByte << 3; + const cr1 = initial[x + i]!; + const cr2 = initial[x + i + 1]!; + let zr1 = cr1; + let zi1 = ci; + let zr2 = cr2; + let zi2 = ci; + let b = 0; + for (let j = 0; j < iterations; j++) { + const tr1 = zr1 * zr1; + const ti1 = zi1 * zi1; + zi1 = 2 * zr1 * zi1 + ci; + zr1 = tr1 - ti1 + cr1; + if (tr1 + ti1 > LIMIT) { + b |= 2; + if (b === 3) break; + } + const tr2 = zr2 * zr2; + const ti2 = zi2 * zi2; + zi2 = 2 * zr2 * zi2 + ci; + zr2 = tr2 - ti2 + cr2; + if (tr2 + ti2 > LIMIT) { + b |= 1; + if (b === 3) break; + } + } + res = (res << 2) | b; + } + out.push(~res & 255); + } + return out; +} + +/** Two pairs per value: the loop runs two iterations. */ +function quads(initial: Float64Array, ci: number): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + for (let j = 0; j < 30; j++) { + zr1 = zr1 * zr1 + ci; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + ci; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + } + res = res * 4 + b; + } + return res; +} + +// Loops the jam keeps as they are. The inner loop clears the accumulator. +function resets(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + for (let j = 0; j < 30; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + if (j > 20) b = 0; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's body advances its counter. +function skips(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + let steps = 0; + for (let j = 0; j < 16; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + if (zr1 < 0.5) j = j + 1; + } + res = res * 4 + b; + } + return res; +} + +// The inner loop's start and bound depend on the outer iteration. +function shifted(initial: Float64Array): number { + let res = 0; + for (let i = 0; i < 4; i += 2) { + let zr1 = initial[i]!; + let zr2 = initial[i + 1]!; + let b = 0; + const start = i * 3; + for (let j = start; j < 10 + i; j++) { + zr1 = zr1 * zr1 + 0.2; + if (zr1 > 2) { + b |= 2; + if (b === 3) break; + } + zr2 = zr2 * zr2 + 0.2; + if (zr2 > 2) { + b |= 1; + if (b === 3) break; + } + } + res = res * 4 + b; + } + return res; +} + +const size = 64; +const initial = new Float64Array(size); +for (let x = 0; x < size; x++) initial[x] = (2 * x) / size - 1.5; +let checksum = 0; +const lines: string[] = []; +for (let y = 0; y < size; y += 8) { + const ci = (2 * y) / size - 1; + const bytes = row(initial, ci, size >> 3, y % 16 === 0 ? 50 : 3); + lines.push(bytes.map((v) => v.toString(16).padStart(2, "0")).join("")); + for (const v of bytes) checksum = (checksum * 31 + v) % 1000003; +} +console.log(lines.join("\n")); +console.log(checksum); +const q = new Float64Array([0.1, 1.5, -0.3, 3]); +console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0)); +// A row past the end of the coordinates reads NaN, which never escapes. +console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2)); +const r = new Float64Array([1.2, 0.5, 1.3, 0.9]); +const s = new Float64Array([0.1, 0.1, 0.73, 0.74]); +console.log(resets(q), resets(r), skips(q), skips(s), shifted(q), shifted(r)); diff --git a/tests/fixtures/fs-write-sync-sigpipe.ts b/tests/fixtures/fs-write-sync-sigpipe.ts index e5fca1d71..24c0012b4 100644 --- a/tests/fixtures/fs-write-sync-sigpipe.ts +++ b/tests/fixtures/fs-write-sync-sigpipe.ts @@ -5,12 +5,42 @@ import * as os from "node:os"; import * as path from "node:path"; const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe"; -if (mode === "sigpipe") { +if (mode === "sigpipe" || mode === "stream-sigpipe" || mode === "stream-then-sync-sigpipe") { const sleeper = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(sleeper, 0, 0, 100); } -if (mode === "eagain") { +if (mode === "stream-sigpipe") { + // a write stream's writes run on the fs pool's threads + const out = fs.createWriteStream("", { fd: 1 }); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + }); + out.write("probe\n"); +} else if (mode === "stream-then-sync-sigpipe") { + // a pool thread writes first; the calling thread's writeSync after it + // keeps its own guard + const scratch = path.join(os.tmpdir(), `scr-write-then-sync-${process.pid}`); + const file = fs.createWriteStream(scratch); + file.end("pool\n", () => { + fs.unlinkSync(scratch); + try { + fs.writeSync(1, "probe\n"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.error("sync caught:", err.name, err.code); + } + }); +} else if (mode === "stream-efbig") { + const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`); + const out = fs.createWriteStream(scratch); + out.on("error", (err: NodeJS.ErrnoException) => { + console.error("stream caught:", err.name, err.code, err.message); + fs.unlinkSync(scratch); + }); + out.write(Buffer.alloc(512)); + out.write(Buffer.alloc(4096)); +} else if (mode === "eagain") { const data = Buffer.alloc(65536); for (;;) { try { diff --git a/tests/fixtures/node-island/exit-tls.cjs b/tests/fixtures/node-island/exit-tls.cjs new file mode 100644 index 000000000..230902e81 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.cjs @@ -0,0 +1,16 @@ +// Island work that keeps OpenSSL busy while the program exits +// (exit-tls.mjs): loading node:tls starts Node's off-thread loading of the +// root certificates (system ones with NODE_USE_SYSTEM_CA=1, extra ones with +// NODE_EXTRA_CA_CERTS); pbkdf2 and key generation run on libuv's pool. +"use strict"; +const crypto = require("node:crypto"); +const tls = require("node:tls"); + +exports.warm = function warm(mode) { + if (mode === "load") return tls.DEFAULT_MIN_VERSION.length > 0; + tls.createSecureContext(); + const roots = tls.rootCertificates.length > 0; + for (let i = 0; i < 8; i++) crypto.pbkdf2("password", "salt", 20000, 64, "sha512", () => {}); + if (mode === "keys") crypto.generateKeyPair("ec", { namedCurve: "P-256" }, () => {}); + return roots; +}; diff --git a/tests/fixtures/node-island/exit-tls.mjs b/tests/fixtures/node-island/exit-tls.mjs new file mode 100644 index 000000000..29dfd69e1 --- /dev/null +++ b/tests/fixtures/node-island/exit-tls.mjs @@ -0,0 +1,20 @@ +// Exit while Node has OpenSSL work in flight (node-island-exit.test.ts): +// Node must stop its threads before the process's exit handlers tear +// OpenSSL down. argv[2]: "early" (exit right after Node starts, while it +// loads the system root certificates on a thread of its own with +// NODE_USE_SYSTEM_CA=1), "load" (node:tls loaded, extra certificates +// loading off-thread), "exit" (process.exit with pool work in flight), +// "keys", or "return" (fall off the end). A "-return" suffix ends the +// program by returning instead of process.exit (C exit and its handlers). +import { requireModule } from "@scriptc/node"; + +const arg = process.argv[2] ?? "exit"; +const returns = arg.endsWith("-return") || arg === "return"; +const mode = arg.replace(/-return$/, ""); +if (mode === "early") { + console.log("roots", requireModule("./helper.cjs") !== null); +} else { + const lib = requireModule("./exit-tls.cjs"); + console.log("roots", lib.warm(mode)); +} +if (!returns) process.exit(0); diff --git a/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json new file mode 100644 index 000000000..0b84e3c8a --- /dev/null +++ b/tests/harness/corpus-costs/async-object-method-contextual-return.ts.json @@ -0,0 +1 @@ +{ "plain": 12154, "san": 118497 } diff --git a/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json new file mode 100644 index 000000000..11d5632f1 --- /dev/null +++ b/tests/harness/corpus-costs/filter-predicate-model-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 12080, "san": 11582 } diff --git a/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json new file mode 100644 index 000000000..ac2bc9413 --- /dev/null +++ b/tests/harness/corpus-costs/map-untyped-empty-fallback.ts.json @@ -0,0 +1 @@ +{ "plain": 3815, "san": 6037 } diff --git a/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/receiver-before-effectful-key.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/saturating-loop-jams.ts.json b/tests/harness/corpus-costs/saturating-loop-jams.ts.json new file mode 100644 index 000000000..e615532ed --- /dev/null +++ b/tests/harness/corpus-costs/saturating-loop-jams.ts.json @@ -0,0 +1 @@ +{ "plain": 5955, "san": 72997 } diff --git a/tests/harness/fs-write-sync.test.ts b/tests/harness/fs-write-sync.test.ts index bd277cd1c..1c8b6c41e 100644 --- a/tests/harness/fs-write-sync.test.ts +++ b/tests/harness/fs-write-sync.test.ts @@ -1,7 +1,9 @@ -/* fs.writeSync contracts that need harness control over the native process's - * descriptors. The ordinary byte/error behavior stays in differential corpus - * 2685; this test controls stdout's pipe state, nonblocking mode, and the - * process file-size limit to pin the signal-backed descriptor errors. */ +/* fs.writeSync and write-stream contracts that need harness control over the + * native process's descriptors. The ordinary byte/error behavior stays in + * differential corpus 2685; this test controls stdout's pipe state, + * nonblocking mode, and the process file-size limit to pin the signal-backed + * descriptor errors, on the calling thread (writeSync) and on the fs pool's + * threads (write streams). */ import { execFileSync, spawn } from "node:child_process"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -80,6 +82,18 @@ describe.skipIf(process.platform === "win32")( "positioned first: 512\n" + "positioned caught: Error EFBIG EFBIG: file too large, write\n", ); + + const streamPipe = await run(result.binaryPath, ["stream-sigpipe"], true); + expect(streamPipe.outcome).toEqual({ code: 0, signal: null }); + expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n"); + + const thenSync = await run(result.binaryPath, ["stream-then-sync-sigpipe"], true); + expect(thenSync.outcome).toEqual({ code: 0, signal: null }); + expect(thenSync.stderr).toBe("sync caught: Error EPIPE\n"); + + const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]); + expect(streamLimit.outcome).toEqual({ code: 0, signal: null }); + expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n"); }); }, ); diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp index 41125589f..a0fe1c5ae 100644 --- a/tests/harness/lsan.supp +++ b/tests/harness/lsan.supp @@ -2,3 +2,24 @@ # (scripts/ci-test-plan.mjs). One `leak:` line per intentional # leak, under a comment that gives the reason. Empty: no corpus program # leaks. + +# Node islands (scope-B NI-7c): OpenSSL inside libnode is initialized with +# OPENSSL_INIT_NO_ATEXIT (scr_node_bridge.cc, KeepOpenSSLAtExit), so its +# process-lifetime state is never freed: OPENSSL_cleanup at exit raced the +# threads Node owns that can still be inside OpenSSL. +leak:OPENSSL_init_crypto +# Node islands (scope-B NI-7c): Node's root certificates (bundled, system +# store, NODE_EXTRA_CA_CERTS) live in function-local statics that Node's +# per-process teardown frees; a scriptc program leaves that teardown to +# process exit (scr_node_bridge.cc, Stop), and the vectors' own destructors +# drop the last pointers at exit. +leak:node::crypto::LoadCACertificates +leak:node::crypto::LoadSystemCACertificates +leak:node::crypto::GetBundledRootCertificates +leak:node::crypto::GetSystemStoreCACertificates +leak:node::crypto::GetExtraCACertificates +leak:node::crypto::GetOpenSSLSystemCertificates +leak:node::crypto::LoadCertsFromBIO +# The certificates' lazily decoded keys, cached in them when a root store +# is built. +leak:node::crypto::NewRootCertStore diff --git a/tests/harness/node-island-exit.test.ts b/tests/harness/node-island-exit.test.ts new file mode 100644 index 000000000..eb69623f4 --- /dev/null +++ b/tests/harness/node-island-exit.test.ts @@ -0,0 +1,87 @@ +/* Exit with Node loaded (scope-B NI-7c): island code loads node:tls (Node + * then loads the root certificates on threads of its own) or leaves OpenSSL + * work on libuv's thread pool, and the program exits at once. The bridge's + * stop runs Node's own exit sequence (join the pool, dispose the platform, + * join the certificate threads) before the process's exit handlers run + * OpenSSL's cleanup; without it a certificate thread inside OpenSSL crashed + * the exit (SIGSEGV/SIGABRT in up to half the runs). + * Many runs per mode; SCRIPTC_NODE_ISLAND_EXIT_RUNS scales them. Needs a + * libnode pack in SCRIPTC_LIBNODE. */ +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; +import { libnodePack } from "./libnode.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixture = join(repoRoot, "tests/fixtures/node-island/exit-tls.mjs"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-exit"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const pack = libnodePack(); +const runs = Number(process.env["SCRIPTC_NODE_ISLAND_EXIT_RUNS"] ?? (sanitize ? "10" : "40")); + +let binary = ""; +// Node loads the system and extra root certificates on their own threads +// when node:tls loads, with these set: the threads the exit must wait for. +const caFile = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"].find((f) => + existsSync(f), +); +const env: NodeJS.ProcessEnv = { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + ...(caFile === undefined ? {} : { NODE_EXTRA_CA_CERTS: caFile }), +}; + +async function runOnce(mode: string): Promise<{ code: number | string; stdout: string }> { + try { + const { stdout } = await execFileAsync(binary, [mode], { encoding: "utf8", env }); + return { code: 0, stdout }; + } catch (err) { + const e = err as { code?: number; signal?: string; stdout?: string }; + return { code: e.signal ?? e.code ?? "?", stdout: e.stdout ?? "" }; + } +} + +describe.skipIf(pack === null)("Node islands: exit with OpenSSL work in flight", () => { + beforeAll(async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(fixture, { + outPath: join(outDir, "exit-tls"), + outDir, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + binary = result.binaryPath; + }, 300_000); + + test.each([ + "early", + "load", + "exit", + "keys", + "return", + "early-return", + "load-return", + "keys-return", + ])( + "%s: every run exits 0", + async (mode) => { + const outcomes: (number | string)[] = []; + for (let i = 0; i < runs; i += 4) { + const batch = await Promise.all( + Array.from({ length: Math.min(4, runs - i) }, () => runOnce(mode)), + ); + for (const r of batch) { + outcomes.push(r.code); + expect(r.stdout).toBe(`roots true\n`); + } + } + expect(outcomes.filter((c) => c !== 0)).toEqual([]); + }, + 600_000, + ); +});