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/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 030b03b4b..80281bb2d 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,6 +5,7 @@ A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way. The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 7083f865d..32af01186 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -5,3 +5,5 @@ - **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them. A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element. Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction. + A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node. + Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there). diff --git a/native/llvm-codegen/src/target.cpp b/native/llvm-codegen/src/target.cpp index 2695d478f..a4c442935 100644 --- a/native/llvm-codegen/src/target.cpp +++ b/native/llvm-codegen/src/target.cpp @@ -127,6 +127,9 @@ TargetOptions targetOptions(const Triple &TargetTriple) { // The Android API 26 runtime pack uses the NDK's emulated TLS ABI. // Program globals and runtime thread instances must use the same ABI. Options.EmulatedTLS = TargetTriple.isAndroid(); + // Program constructors go in .init_array, which the C runtime's startup + // files run on ELF, as runtime units' constructors do (runtime-unit.cpp). + Options.UseInitArray = TargetTriple.isOSBinFormatELF(); if (ProfileLayout && TargetTriple.isOSBinFormatELF()) { Options.FunctionSections = true; Options.EnableMachineFunctionSplitter = true; 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/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..044926914 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -387,9 +387,43 @@ declare module "node:fs" { readonly ctime: Date; readonly birthtime: Date; } + /* Node's BigIntStats: the same snapshot under the bigint member table. + * Every number member of Stats — including the *Ms millisecond members — + * answers a bigint, and the *Ns nanosecond members sit beside them; the + * four time members stay Dates. */ + export interface BigIntStats { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isFIFO(): boolean; + isSocket(): boolean; + readonly dev: bigint; + readonly mode: bigint; + readonly nlink: bigint; + readonly uid: bigint; + readonly gid: bigint; + readonly rdev: bigint; + readonly blksize: bigint; + readonly ino: bigint; + readonly size: bigint; + readonly blocks: bigint; + readonly atimeMs: bigint; + readonly mtimeMs: bigint; + readonly ctimeMs: bigint; + readonly birthtimeMs: bigint; + readonly atimeNs: bigint; + readonly mtimeNs: bigint; + readonly ctimeNs: bigint; + readonly birthtimeNs: bigint; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; + } /* throwIfNoEntry: false answers undefined for a missing entry (ENOENT or - * ENOTDIR) instead of throwing. BigIntStats (bigint: true) has no - * lowering. */ + * ENOTDIR) instead of throwing. bigint: true answers BigIntStats. */ export interface StatSyncOptions { bigint?: boolean; throwIfNoEntry?: boolean; @@ -399,24 +433,38 @@ declare module "node:fs" { path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; + export function statSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry: false }, + ): BigIntStats | undefined; export function statSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; - /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function statSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function statSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry?: true }, + ): BigIntStats; + /* A runtime-valued `bigint` is refused at lowering, so the catch-all keeps + * the Stats answer the other reachable forms give. */ export function statSync(path: string | URL, options: StatSyncOptions): Stats | undefined; export function lstatSync(path: string | URL, options?: undefined): Stats; export function lstatSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; + export function lstatSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry: false }, + ): BigIntStats | undefined; export function lstatSync( path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; - /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function lstatSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function lstatSync( + path: string | URL, + options: StatSyncOptions & { bigint: true; throwIfNoEntry?: true }, + ): BigIntStats; export function lstatSync(path: string | URL, options: StatSyncOptions): Stats | undefined; /* The fd pair behind spawn's fd-stdio form: openSync(path, flags) → * the raw fd. Numeric flags use an inline OR of the O_* constants. */ @@ -498,8 +546,9 @@ declare module "node:fs" { position?: number | null, encoding?: "utf8" | "utf-8", ): number; - /* statSync over an open fd — the same Stats snapshot. */ - export function fstatSync(fd: number): Stats; + /* statSync over an open fd — the same snapshot. */ + export function fstatSync(fd: number, options?: StatSyncOptions & { bigint?: false }): Stats; + export function fstatSync(fd: number, options: StatSyncOptions & { bigint: true }): BigIntStats; /* realpath(3) — resolves symlinks, `.`/`..`, throwing Node's fs error * shapes for missing paths. */ export function realpathSync(path: string | URL): string; 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/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index bbf3cdfff..f02ffbd9c 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -53,6 +53,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ emitter: features.emitter, symbol: features.symbol, bigint: features.bigint, + ...(features.bigstats ? { bigstats: true } : {}), searchParams: features.searchParams, qs: features.qs, utilPromisify: features.utilPromisify, 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/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb..58bad60af 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2889,6 +2889,21 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${ek})`); }); } + // A record built from a frozen object is frozen too, as the object it + // copies (frozen-records.ts makes such shapes freezable). + if (shape.freezable) { + host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`); + host.declare(`declare void @scr_record_freeze(ptr)`); + const frozen = B.tmp(); + const lFreeze = B.newLabel("dcr.freeze"); + const lDone = B.newLabel("dcr.frozen"); + B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`); + B.condBr(frozen, lFreeze, lDone); + B.startBlock(lFreeze); + B.line(`call void @scr_record_freeze(ptr %r0)`); + B.br(lDone); + B.startBlock(lDone); + } B.terminate(`ret ptr %r0`); break; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6980c8f45..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)), + ), ), ), ), @@ -4620,6 +4623,7 @@ export class LlEmitter { case "url": case "searchParams": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": 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/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 57a09d85d..8378fabea 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -1170,6 +1170,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "dir": case "watcher": case "stats": + case "bigstats": case "zlib": case "atomics": return host.emitFilesystemLibCall(e); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 9174f4775..ea275738e 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -1,7 +1,7 @@ /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js"; +import { DYN, F64, STRING, type IrExpr } from "../../ir/ir.js"; import { mangleRecordNew } from "../mangle.js"; import { arrNewCall, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; @@ -551,9 +551,12 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): if (e.type.kind !== "union") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`); const def = host.unionsById.get(e.type.unionId); - const statsTag = def ? def.arms.findIndex((a) => a.kind === "stats") : -1; + const statsTag = def + ? def.arms.findIndex((a) => a.kind === "stats" || a.kind === "bigintStats") + : -1; + const statsArm = def?.arms[statsTag]; const undefTag = undefinedArmTag(e.type, host.unionsById); - if (statsTag < 0 || undefTag < 0) + if (statsTag < 0 || undefTag < 0 || !statsArm) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); const path = host.emitExpr(e.args[0]!); const throws = host.emitExpr(e.args[1]!); @@ -564,7 +567,7 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): `${raw} = call ptr @scr_fs_stat_probe(ptr ${path.name}, i1 zeroext ${noFollow}, i1 zeroext ${throws.name})`, ); host.emitPendingCheck(); - return host.wrapNullable(raw, raw, STATS_T, statsTag, e.type, undefTag); + return host.wrapNullable(raw, raw, statsArm, statsTag, e.type, undefTag); } if (e.fn === "fs.readdirTypesSync" || e.fn === "fsp.readdirTypes") { // Dirent rows assembled inline from one scandir snapshot — the C diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 694f8e8f0..97a13ea16 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -383,6 +383,7 @@ export const LIB_FN_SYMS: Record = { "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", + "process.isTTYValue": "scr_process_is_tty_value", // A promise that never settles (events.once of an event a synchronous // stream never emits); a fresh pending promise is its owned result. "events.pendingForever": "scr_promise_new", @@ -625,6 +626,35 @@ export const LIB_FN_SYMS: Record = { "stats.mtime": "scr_stats_mtime", "stats.ctime": "scr_stats_ctime", "stats.birthtime": "scr_stats_birthtime", + "bigstats.isFile": "scr_stats_is_file", + "bigstats.isDirectory": "scr_stats_is_dir", + "bigstats.isSymbolicLink": "scr_stats_is_symlink", + "bigstats.isBlockDevice": "scr_stats_is_block_device", + "bigstats.isCharacterDevice": "scr_stats_is_character_device", + "bigstats.isFIFO": "scr_stats_is_fifo", + "bigstats.isSocket": "scr_stats_is_socket", + "bigstats.size": "scr_stats_big_size", + "bigstats.dev": "scr_stats_big_dev", + "bigstats.ino": "scr_stats_big_ino", + "bigstats.blocks": "scr_stats_big_blocks", + "bigstats.nlink": "scr_stats_big_nlink", + "bigstats.mode": "scr_stats_big_mode", + "bigstats.uid": "scr_stats_big_uid", + "bigstats.gid": "scr_stats_big_gid", + "bigstats.rdev": "scr_stats_big_rdev", + "bigstats.blksize": "scr_stats_big_blksize", + "bigstats.atimeMs": "scr_stats_big_atime_ms", + "bigstats.mtimeMs": "scr_stats_big_mtime_ms", + "bigstats.ctimeMs": "scr_stats_big_ctime_ms", + "bigstats.birthtimeMs": "scr_stats_big_birthtime_ms", + "bigstats.atimeNs": "scr_stats_big_atime_ns", + "bigstats.mtimeNs": "scr_stats_big_mtime_ns", + "bigstats.ctimeNs": "scr_stats_big_ctime_ns", + "bigstats.birthtimeNs": "scr_stats_big_birthtime_ns", + "bigstats.atime": "scr_stats_big_atime", + "bigstats.mtime": "scr_stats_big_mtime", + "bigstats.ctime": "scr_stats_big_ctime", + "bigstats.birthtime": "scr_stats_big_birthtime", "text.decode": "scr_text_decode", "bytes.bufferSource": "scr_bytes_buffer_source", "bytes.copyBrand": "scr_bytes_copy_brand", @@ -1061,7 +1091,7 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.defer": "scr_node_island_defer", "nodeIsland.import": "scr_ni_import", "nodeIsland.get": "scr_node_island_get", - "nodeIsland.getKey": "scr_ni_get", + "nodeIsland.getKey": "scr_node_island_get_key", "nodeIsland.set": "scr_node_island_set", "nodeIsland.setKey": "scr_node_island_set_key", "nodeIsland.invoke": "scr_node_island_invoke", @@ -1069,6 +1099,7 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.applyTo": "scr_node_island_apply_to", "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", + "nodeIsland.isArray": "scr_ni_is_array", "nodeIsland.classMethods": "scr_node_island_class_methods", "nodeIsland.subclass": "scr_node_island_subclass", "nodeIsland.superConstruct": "scr_node_island_super_construct", diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 8e9b1434d..f2cc49cb0 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -682,7 +682,11 @@ export function emitBorrowedArrayRead( B.startBlock(slow); host.declare("declare ptr @scr_arr_borrow_ref(ptr, double)"); const value = B.tmp(); - B.line(`${value} = call ptr @scr_arr_borrow_ref(ptr ${array.name}, double ${index.name})`); + // The fallback only reads the array (a hole or an out-of-range index + // traps), so loops keep the array's header loads hoisted past it. + B.line( + `${value} = call ptr @scr_arr_borrow_ref(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, + ); B.line(`store ptr ${value}, ptr ${slot}`); B.br(join); B.startBlock(join); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index 675064c74..7ba739f08 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -143,6 +143,7 @@ export type NullableRefArm = | { kind: "record"; shapeId: string } | { kind: "string" } | { kind: "stats" } + | { kind: "bigintStats" } | (IrType & { kind: "array" | "bytes" | "map" | "set" | "func" }); /** The arm is a JS object: always truthy. */ @@ -367,6 +368,7 @@ function nullableShape( return { ...base, arm: { kind: "record", shapeId: arm.shapeId } }; if (arm.kind === "string") return { ...base, arm: { kind: "string" } }; if (arm.kind === "stats") return { ...base, arm: { kind: "stats" } }; + if (arm.kind === "bigintStats") return { ...base, arm: { kind: "bigintStats" } }; if ( arm.kind === "array" || arm.kind === "bytes" || 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/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 5574d1dde..0f4d18370 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -56,6 +56,7 @@ const LEAF_KINDS: ReadonlySet = new Set([ "url", "searchParams", "stats", + "bigintStats", "undefinedT", "nullT", "void", diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 7ef02a978..e02ae5cf8 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -94,6 +94,7 @@ const INERT_LIB_CALLS = new Set([ "dyn.compare", // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", + // A sloppy-mode function's receiver from it (a fresh reference). "error.new", "error.nodeThrow", "dyn.typedRefIs", @@ -119,6 +120,7 @@ const INERT_LIB_CALLS = new Set([ "process.platform", "process.execPath", "process.isTTY", + "process.isTTYValue", "worker.data", "worker.isMainThread", "worker.parentPort", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 87983c8b9..3963e0409 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -1304,6 +1304,7 @@ function arrUsesRef(host: ShapeHost, elem: IrType): boolean { elem.kind === "child" || // spawned child handles: scr_child_* adapters, no trace elem.kind === "netServer" || elem.kind === "stats" || // fs.Stats snapshots: scr_stats_* adapters, no trace + elem.kind === "bigintStats" || // the same snapshot handle, BigIntStats reads elem.kind === "dyn" || // native collection seeds/drains: traced REF elem.kind === "regex" || // RegExp values: scr_regex_* adapters, no trace (no refs inside) elem.kind === "date" || // Date objects: scr_date_* adapters, no trace (no refs inside) 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/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 43d4efa22..39fbde902 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -103,6 +103,10 @@ export interface NativeLinkFeatures { emitter: boolean; symbol: boolean; bigint: boolean; + /** BigIntStats member reads (`statSync(p, { bigint: true })` members): + * scr_stats_bigint.c. Absent (false) otherwise, so BigInt programs that + * never read a stat member keep their link line and size. */ + bigstats?: boolean; searchParams: boolean; qs: boolean; utilPromisify: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 7a332e11f..8a64d099e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -194,6 +194,9 @@ export interface CcOptions { * Symbol-free binaries keep their exact link line. */ symbol?: boolean; bigint?: boolean; + /** BigIntStats member reads: compiles scr_stats_bigint.c. Every other + * bigint program keeps its exact link line and size. */ + bigstats?: boolean; /** Records URLSearchParams use in the build identity. Its runtime unit is * part of the base sources because checked URL handles expose searchParams. */ searchParams?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3ec34ca7c..8866d0829 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -153,6 +153,11 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.copying, "scr_copying.c"), unit((u) => u.opts.fileHandle, "scr_file_handle.c"), + // BigIntStats member reads (`statSync(p, { bigint: true })` integers and + // `*Ns`/`*Ms` members). Gated on the reads themselves (the bigstats + // feature), not on bigint: every bigint value or wasm conversion turns + // bigint on, and the linked getters would move their executable. + unit((u) => u.opts.bigstats === true, "scr_stats_bigint.c"), // win32 targets compile the libc-shim TU (stpcpy, arc4random_buf, // gmtime_r, strcasestr — the _WIN32 block in scr_runtime.h declares // them) and link advapi32 (the CSPRNG RtlGenRandom/SystemFunction036, @@ -186,7 +191,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), - unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), + unit( + (u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch || u.opts.nodeIsland === true, + "scr_dyn_invoke.c", + ), // Buffer.prototype methods called on checked-dynamic values. unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), // Builtin functions read as values in JavaScript dependencies. 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/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index 1e35903cd..f3d7681d5 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -117,7 +117,9 @@ export const DIAGNOSTIC_CODES = { SC8010: "value crossing a Node island boundary in a form without an exact copy", SC8011: "native class extending a Node island class outside the supported forms", SC8020: "stateful module duplicated across the Node island boundary", + SC8021: "Node island evaluation notes (deferred or at startup)", SC8022: "island code extends a class of a compiled module", + SC8023: "Node island build failed; built natively with runtime fences", SC8030: "type patch: no range covers the installed copy", SC8031: "type patch: selector matches nothing", SC8032: "type patch: declaration already has the type", @@ -156,8 +158,6 @@ export const RETIRED_DIAGNOSTIC_CODES = { * abandoned, delete them here. */ export const RESERVED_DIAGNOSTIC_CODES = { SC8012: "Node islands (ni/* branches)", - SC8021: "Node islands (ni/* branches)", - SC8023: "Node islands (ni/* branches)", } as const satisfies Record<`SC${number}`, string>; export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES; diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 3322b3af8..cd604cfe7 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -460,6 +460,23 @@ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic }; } +/** SC8023 — the build that ran fenced packages as Node islands failed, so + * the program was built natively with those runtime fences (each throws + * when reached). A warning on the native build, with the island build's + * first error. */ +export function nodeIslandRetryFailedDiag( + packages: readonly string[], + first: ScrDiagnostic, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8023", + message: `running ${packages.map((p) => `'${p}'`).join(", ")} in Node islands failed to compile (${first.code} at ${first.loc.file}:${first.loc.start}: ${first.message}); built natively instead, where their runtime fences throw when reached`, + loc, + hint: "fix the island build's error, or mark the packages with --node-island= to see every error", + }; +} + /** SC8030–SC8033 — a type patch (`scriptc.typePatches`) that did not * apply as written: no range covers the installed copy (SC8030), its * selector matches nothing (SC8031), the declaration already has the type @@ -1043,6 +1060,33 @@ export function noLoweringDiag( }; } +/** A stdlib-global snapshot reached a site that needs its VALUE (an + * argument, a return, a stored field). The declaration emitted no storage + * — alias plumbing only serves member and call reads — and the global's + * own value has no lowering, so the read cannot resolve. Reported at the + * declaration, where the real cause sits; the use site carries the SC2004 + * cascade. */ +export function stdlibSnapshotValueUseDiag( + name: string, + alias: string, + typeText: string | undefined, + loc: SrcLoc, +): ScrDiagnostic { + // Name the requested type when it has a readable spelling; a structural + // dump of the global's own object type says less than the global itself. + const unholdable = + typeText === undefined + ? "the global it names is not a value scriptc can hold" + : `a '${typeText}' is not a value scriptc can hold`; + return { + code: "SC2020", + message: `the stdlib-global snapshot '${name}' ('${alias}') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and ${unholdable}`, + loc, + milestone: "later", + hint: `read the members through the global directly ('${alias}.member') instead of passing '${name}' around as a value`, + }; +} + /** A REACHED use of a part of the WebAssembly API scriptc does not * implement yet (SC2020): the streaming functions, Tag and Exception, the * classes as values. compile, instantiate, validate, the Module, Instance, @@ -1134,6 +1178,30 @@ export function unionMismatchDiag( }; } +/** An object literal that SPREADS union-typed sources (`{ ...base, + * ...override }` where each source is `A | B | C`) builds one field set per + * combination of source arms. A union of records cannot hold that: each arm + * carries its own field table, so naming one arm would drop the fields the + * other arms contributed (Node keeps every field). The refusal names the + * candidate arms, because the honest repair is an explicit annotation per + * source (or one open record type), never a guessed arm. */ +export function unionLiteralMismatchDiag( + expectedText: string, + arms: readonly string[], + loc: SrcLoc, +): ScrDiagnostic { + const named = arms.map((a) => `'${a}'`).join(", "); + return { + code: "SC2003", + message: + `a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '${expectedText}' can represent it` + + (arms.length > 0 ? ` (candidate arms: ${named})` : ""), + loc, + milestone: "later", + hint: "merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms", + }; +} + /** A use of a binding whose DECLARATION did not compile: the declaration * site already carries the real diagnostic (a fenced type, a blocked * signature, an unsupported binding form), and every later reference falls diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 144e3f860..fdc09bae9 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -234,6 +234,19 @@ export interface NodeIslandsEnvelope { unknownPackages: string[]; } +/** Node island notices of a build that succeeds (scope-B): SC8001 and + * SC8021 notes (a package runs in a Node island, an island evaluates at + * boot) and the SC8020 warning (a stateful module duplicated across the + * boundary). The envelope reports all three as warnings. */ +export function isNodeIslandNoticeCode(code: string): boolean { + return code === "SC8001" || code === "SC8020" || code === "SC8021" || code === "SC8023"; +} + +/** SC8001 and SC8021 are notes, not warnings. */ +export function isNodeIslandNoteCode(code: string): boolean { + return code === "SC8001" || code === "SC8021"; +} + /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { @@ -370,6 +383,7 @@ export function locateDiagnostic( severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || + isNodeIslandNoticeCode(diag.code) || diag.code === "SC8004" || isTypePatchCode(diag.code) ? "warning" diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index a370eb3b1..d73a9ce9d 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -4,6 +4,7 @@ * longer than FRAME_MAX_WIDTH (minified sources) shows a window of that * width around the diagnostic's column, with "…" where text is cut. */ +import { isNodeIslandNoteCode, isNodeIslandNoticeCode } from "./envelope.js"; import { isTypePatchCode, type ScrDiagnostic } from "./diagnostic.js"; export interface RenderOptions { @@ -102,14 +103,21 @@ export function renderDiagnostic( frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + " ".repeat(caretCol) + - c(isWarningCode(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), + c( + isWarningCode(diag.code) || isNodeIslandNoticeCode(diag.code) ? YELLOW : RED, + "^" + "~".repeat(spanOnLine - 1), + ), ); emitLine(lineNum + 1); } - const warning = isWarningCode(diag.code); + // Node island notices of a successful build: SC8020 warns, SC8001 and + // SC8021 are notes. + const note = isNodeIslandNoteCode(diag.code); + const warning = isWarningCode(diag.code) || diag.code === "SC8020" || diag.code === "SC8023"; + const severity = note ? c(CYAN, "note") : warning ? c(YELLOW, "warning") : c(RED, "error"); out.push( - `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`, + `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${severity} ${c(BOLD, diag.code)}: ${diag.message}`, ); if (frame.length) { out.push("", ...frame); @@ -191,9 +199,11 @@ export function renderWarnings( sourceTextByFile: Map, opts: RenderOptions = {}, ): string { - const n = diags.length; + const n = diags.filter((d) => !isNodeIslandNoteCode(d.code)).length; + const rendered = renderDiagnostics([...diags], sourceTextByFile, opts); + if (n === 0) return rendered; return ( - renderDiagnostics([...diags], sourceTextByFile, opts) + + rendered + `\n\n${n} warning${n === 1 ? "" : "s"}: the program compiled, but ${n === 1 ? "this site" : "these sites"} can behave differently from Node.` ); } diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 702adc403..534c84b93 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -98,6 +98,8 @@ export interface EarlyExecutableNativeFeatures { emitter: boolean; symbol: boolean; bigint: boolean; + /** BigIntStats member reads: scr_stats_bigint.c. Absent (false) otherwise. */ + bigstats?: boolean; searchParams: boolean; qs: boolean; utilPromisify: boolean; @@ -174,6 +176,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`). */ @@ -345,6 +349,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), @@ -395,6 +400,7 @@ function routeKey(options: Omit; let disposed = false; + let fenceGrown: Map | null = null; + const fenceSure = new Set(); // The frontend (and its tsgo server) is released as soon as lowering // ends — clang and the link never hold it open. try { @@ -166,6 +168,31 @@ export function prepareExecutableModule( retryFences, ); } + // A failed program whose compiled packages need the runtime or a + // mutable object model (NI-2's report): try them as islands. If that + // build fails too, its failure is the one reported. This build typed + // those packages from their JavaScript, where the types they only + // declare (interfaces, aliases such as `Static`) are `any`, so its + // diagnostics describe a program the island build does not compile: + // there the packages' declarations type the user code. + const marked = retryFences + ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) + : null; + if (marked !== null) { + fe.dispose(); + frontendDisposed(); + disposed = true; + return prepareExecutableModule( + entryPath, + opts, + ffi, + buildPlatform, + createFrontend, + timing, + marked, + false, + ); + } return fail(lowered.diagnostics); } @@ -196,10 +223,6 @@ export function prepareExecutableModule( return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]); } } - // The package import edges island selection reads after lowering, taken - // while the frontend is open. - if (retryFences && lowered.runtimeFences.some(sureFence)) - fenceEdges = fe.nodeIslandScan?.({ userJs: false }).edges; sourceTexts = fe.sourceTexts(); if (lowered.contracts) { const checked = checkContracts(lowered.module, lowered.contracts, sourceTexts); @@ -207,6 +230,8 @@ export function prepareExecutableModule( if (checked.diagnostics.length > 0) return fail(checked.diagnostics); if (checked.contracts) lowered.module.contracts = checked.contracts; } + if (retryFences) + fenceGrown = fenceIslands(entryPath, opts, fe, lowered, lowerIslands, true, fenceSure); } finally { if (!disposed) { fe.dispose(); @@ -227,17 +252,21 @@ export function prepareExecutableModule( ...(lowered.performance ?? []), ], }; - // Runtime fences in a compiled package that surely fire: fences that need - // the JavaScript runtime itself (R1: code from strings, computed - // import()/require(), addons, loader hooks), and fences in top-level - // module code, which throw when the module evaluates. Build again with - // those packages as islands, and keep this build when that one cannot - // compile. A gap fence inside a function body may never run, so it stays - // a deferred refusal. - const runtimeFences = lowered.runtimeFences.filter(sureFence); - if (retryFences && runtimeFences.length > 0) { - const grown = loweringIslands(entryPath, opts, fe, runtimeFences, lowerIslands, fenceEdges); - if (grown !== null) { + // Packages compiled with runtime fences that need the JavaScript + // runtime or a mutable object model (fenceIslands): build again with + // them as islands, and keep this build when that one cannot compile. + // If that build fails, retry with only the packages whose fences fire + // for sure (module-init or runtime fences); if that fails too, the native + // build carries an SC8023 warning with the island build's first error, + // never a silent fallback. + if (fenceGrown !== null) { + const attempts = [fenceGrown]; + const sure = new Map( + [...fenceGrown].filter(([name]) => lowerIslands.has(name) || fenceSure.has(name)), + ); + if (sure.size > lowerIslands.size && sure.size < fenceGrown.size) attempts.push(sure); + let firstError: ScrDiagnostic | null = null; + for (const islands of attempts) { const islanded = prepareExecutableModule( entryPath, opts, @@ -245,21 +274,22 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + islands, false, ); if (islanded.ok) return islanded; + firstError ??= islanded.diagnostics[0] ?? null; + } + if (firstError !== null) { + const added = [...fenceGrown.keys()].filter((name) => !lowerIslands.has(name)); + prepared.warnings.push( + nodeIslandRetryFailedDiag(added, firstError, { file: entryPath, start: 0, end: 0 }), + ); } } return prepared; } -/** A run-time fence that surely fires: it needs the JavaScript runtime - * itself, or it is in top-level module code. */ -function sureFence(d: ScrDiagnostic): boolean { - return d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime"; -} - /** The packages a build runs in Node islands, or null for none: under * `--node-islands=auto` (the default) every npm package the native * pipeline refused, plus packages marked `node`; under `explicit` only the @@ -482,3 +512,109 @@ function preflightIslands( } return grown.size === known.size ? null : grown; } + +/** Packages compiled natively with runtime fences (refusals deferred to the + * operation that reaches them) that should run in islands instead: those + * with a fence that surely fires (one in top-level module code, which runs + * when the module evaluates) or that itself needs the runtime (R1). Other + * fences stay deferred refusals until decision 8 (D6) retires them, so + * typed packages like jose stay native. With `requireFence` false (after a + * failed build) the package's island class by NI-2's report decides + * instead: runtime (R1) or object-model (R2). `--node-islands=auto` only; + * null for none. */ +function fenceIslands( + entryPath: string, + opts: CompileRequestOptions, + fe: ReturnType, + lowered: LowerResult, + known: ReadonlyMap, + requireFence = true, + sure?: Set, +): Map | null { + if (fe.nodeIslandScan === undefined) return null; + if (requireFence && lowered.runtimeFences.length === 0) return null; + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 32) return null; + // An explicit --npm-static request keeps its package native. + const explicit = new Set( + Array.isArray(opts.npmStatic) ? opts.npmStatic.map((n) => n.replace(/\.\.\.$/, "")) : [], + ); + const compiled = new Set( + fe.npmStatic + .filter((e) => e.status === "static" && !explicit.has(e.package)) + .map((e) => e.package), + ); + const fenced = new Map(); + for (const d of lowered.runtimeFences) { + const at = packageDirOf(d.loc.file); + if (at === null || !compiled.has(at.name) || known.has(at.name)) continue; + if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue; + // A fence in top-level module code throws when the module evaluates: + // it counts like one that needs the runtime. + const runtime = d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime"; + fenced.set(at.name, (fenced.get(at.name) ?? false) || runtime); + if (runtime) sure?.add(at.name); + } + if (requireFence && fenced.size === 0) return null; + const scan = fe.nodeIslandScan({ userJs: false }); + const report = buildNodeIslandReport({ + entry: entryPath, + scan, + npm: fe.npmStatic, + diagnostics: lowered.diagnostics, + fences: lowered.runtimeFences, + policy, + }); + const grown = new Map(known); + for (const island of report.islands) { + const runtimeFence = requireFence + ? fenced.get(island.package) + : (fenced.get(island.package) ?? false); + if (runtimeFence === undefined || island.path === null) continue; + if (!compiled.has(island.package) || known.has(island.package)) continue; + if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + // With fences (requireFence), only fences that surely fire count: a + // gap fence inside a function body may never run (jose's typed paths + // stay native, DECISIONS.md 2026-10-21). After a failed build + // (!requireFence), the package's report class decides. + if (requireFence && !runtimeFence) continue; + if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; + grown.set(island.package, { + name: island.package, + version: island.version, + dir: island.path, + class: island.class, + reason: island.reason, + }); + } + // A native ES module that imports an island CommonJS module would read + // its bindings from the module's exports object, where Node gives it the + // namespace its CommonJS facade builds (the lexed names, module.exports + // as the default): the importing package runs in the island too. + for (let added = true; added;) { + added = false; + for (const edge of scan.edges) { + const name = edge.to; + if (!grown.has(name) || edge.fromDir === null || edge.shim) continue; + const at = packageDirOf(edge.fromFile); + if (at === null || grown.has(at.name) || !compiled.has(at.name)) continue; + if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue; + if (!isEsmFile(edge.fromFile)) continue; + const target = + resolveBareModule(edge.fromFile, name, "js-only") ?? + resolveBareModule(edge.fromFile, name, "js-only", "require"); + if (target === null || isEsmFile(target.typesFile)) continue; + grown.set(at.name, { + name: at.name, + version: packageVersionOf(at.dir), + dir: at.dir, + class: "packaging", + reason: `an ES module of the package imports the CommonJS island package '${name}'`, + }); + // It goes wherever that island goes, the sure-fence retry included. + if (known.has(name) || sure?.has(name) === true) sure?.add(at.name); + added = true; + } + } + return grown.size === known.size ? null : grown; +} diff --git a/packages/compiler/src/frontend/lowering/any-collection-residue.ts b/packages/compiler/src/frontend/lowering/any-collection-residue.ts new file mode 100644 index 000000000..7581c0fed --- /dev/null +++ b/packages/compiler/src/frontend/lowering/any-collection-residue.ts @@ -0,0 +1,44 @@ +import { type IrType, typeKey } from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Whether `actual` is `declared` with every `Map` 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/builtin-namespaces.ts b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts index c1e40d2e6..07c6bda5b 100644 --- a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts @@ -19,7 +19,7 @@ import { funcOf, } from "../../ir/ir.js"; import { helperLoc, varRef } from "../../ir/build.js"; -import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { unsupportedDiag, type ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import type { Lowerer } from "./lowerer.js"; import { InternalCompilerError } from "../../errors.js"; import { mathMemberValue } from "./lower-stdlib.js"; @@ -117,21 +117,46 @@ const WRITABLE = 1; const CONFIGURABLE = 4; /** The namespace object the stdlib global `canonical` reads as in - * JavaScript, or null when it is not a namespace. */ + * JavaScript, or null when it is not a namespace. The fences of its members + * without a function value are reported unless `reportFences` is false: the + * program's builtin lookup (global-values.ts) holds every namespace without + * the program naming it, and its members still throw their fences when + * called. */ export function builtinNamespaceValue( lowerer: Lowerer, canonical: string, loc: SrcLoc, + reportFences = true, ): IrExpr | null { const members = namespaceMembers(canonical); if (members === null) return null; const name = `%builtin.namespace.${canonical}`; if (!lowerer.liftedFns.some((fn) => fn.name === name)) defineNamespaceBuilder(lowerer, canonical, members, name, helperLoc(loc)); + if (reportFences) reportMemberFences(lowerer, canonical, members, helperLoc(loc)); lowerer.noteEdge(name); return { kind: "call", callee: name, args: [], type: DYN, loc }; } +/** Reports the SC1090 fence of each member of `canonical` whose function + * throws it (fencedMember), once per program. */ +function reportMemberFences( + lowerer: Lowerer, + canonical: string, + members: readonly NamespaceMember[], + loc: SrcLoc, +): void { + for (const { key, length } of members) { + if (length < 0) continue; + const display = `${canonical}.${key}`; + if (!lowerer.liftedFns.some((fn) => fn.name === fenceName(display))) continue; + const diag = fenceDiag(display, loc); + if (lowerer.runtimeFences.some((d) => d.code === diag.code && d.message === diag.message)) + continue; + lowerer.runtimeFences.push(diag); + } +} + /** The lifted function that builds the namespace object once and answers * the same object afterwards. */ function defineNamespaceBuilder( @@ -253,18 +278,17 @@ function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: Sr return fencedMember(lowerer, `${canonical}.${key}`, key, loc); } +const fenceName = (display: string): string => `%builtin.fence.${display}`; +const fenceDiag = (display: string, loc: SrcLoc): ScrDiagnostic => + unsupportedDiag("SC1090", loc, `'${display}' as a function value (call it directly)`); + /** One function per member without a function value: calling it throws the * SC1090 fence, while `typeof`, identity and the property's presence stay - * as in Node. */ + * as in Node. builtinNamespaceValue reports the fence. */ function fencedMember(lowerer: Lowerer, display: string, key: string, loc: SrcLoc): IrExpr { - const name = `%builtin.fence.${display}`; + const name = fenceName(display); if (!lowerer.liftedFns.some((fn) => fn.name === name)) { - const diag = unsupportedDiag( - "SC1090", - loc, - `'${display}' as a function value (call it directly)`, - ); - lowerer.runtimeFences.push(diag); + const diag = fenceDiag(display, loc); lowerer.liftedFns.push({ name, params: [], diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index d8272c795..7a2b262da 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -12,6 +12,7 @@ import { } from "../surfaces.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { + BIGINT_STATS_T, BOOL, BYTES_U8, DIR_T, @@ -1077,6 +1078,18 @@ export const STATS_TYPE_TESTS: Record = { isSocket: "stats.isSocket", }; +/** The same type tests on a BigIntStats receiver (statSync with + * `{ bigint: true }`): one more snapshot read, no bigint arithmetic. */ +const BIGINT_STATS_TYPE_TESTS: Record = { + isFile: "bigstats.isFile", + isDirectory: "bigstats.isDirectory", + isSymbolicLink: "bigstats.isSymbolicLink", + isBlockDevice: "bigstats.isBlockDevice", + isCharacterDevice: "bigstats.isCharacterDevice", + isFIFO: "bigstats.isFIFO", + isSocket: "bigstats.isSocket", +}; + /** Method calls on Stats-typed receivers: the type tests are pure reads on * the stat snapshot (a followed statSync snapshot never answers true to * isSymbolicLink — take lstatSync's, Node's own split). Anything else @@ -1094,10 +1107,14 @@ export function lowerStatsMethodCall( ) { return null; } - if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "stats") return null; + const receiverKind = lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind; + if (receiverKind !== "stats" && receiverKind !== "bigintStats") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; - const fn = Object.hasOwn(STATS_TYPE_TESTS, name) ? STATS_TYPE_TESTS[name] : undefined; + // BigIntStats' type tests are the same snapshot mode read; only the kind + // of the handle differs. + const table = receiverKind === "bigintStats" ? BIGINT_STATS_TYPE_TESTS : STATS_TYPE_TESTS; + const fn = Object.hasOwn(table, name) ? table[name] : undefined; if (fn && call.arguments.nodes.length === 0) { const receiver = lowerer.lowerExpr(access.expression); return { kind: "libCall", fn, args: [receiver], type: BOOL, loc: locOf(call) }; @@ -1482,14 +1499,71 @@ function lowerFsReaddirNames( /** fs.statSync / fs.lstatSync's documented option keys. */ const FS_STAT_DOCUMENTED_OPTIONS: ReadonlySet = new Set(["bigint", "throwIfNoEntry"]); +/** fs.fstatSync's: an open descriptor never misses, so there is no + * throwIfNoEntry. */ +const FS_FSTAT_DOCUMENTED_OPTIONS: ReadonlySet = new Set(["bigint"]); + +/** The stat representation a mapped result type carries: BigIntStats and + * `BigIntStats | undefined` read the bigint member table, Stats and + * `Stats | undefined` the number one; anything else is neither. */ +function statRepresentation( + callType: IrType | undefined | null, + arms: readonly IrType[] | undefined, +): "bigint" | "number" | null { + const kindOf = (t: IrType): "bigint" | "number" | null => + t.kind === "bigintStats" ? "bigint" : t.kind === "stats" ? "number" : null; + if (callType === undefined || callType === null) return null; + const own = kindOf(callType); + if (own !== null) return own; + if (arms !== undefined) { + for (const arm of arms) { + const repr = kindOf(arm); + if (repr !== null) return repr; + } + } + return null; +} + +/** The `bigint` option member. The literal picks the representation — + * BigIntStats for true, Stats for false — and the call's own type must + * already agree with it, because a `Stats | BigIntStats`-valued flag has no + * static member table. Answers the member's lowered value when evaluating + * it can have an effect (`bigint: (count++, true)`), which the call then + * runs in its place among the options; null when it can be dropped. */ +function checkStatBigintOption( + lowerer: Lowerer, + value: ts.Expression, + spelling: string, + representation: "bigint" | "number" | null, +): IrExpr | null { + const t = lowerer.typeOf(value); + const literal = t.flags & ts.TypeFlags.BooleanLiteral ? lowerer.checker.typeToString(t) : null; + if (literal === null) { + lowerer.noLowering( + `${spelling} with a runtime-valued bigint option`, + value, + "pass the literal true or false: the result's representation is chosen statically", + ); + } + if (representation === null || (literal === "true") !== (representation === "bigint")) { + lowerer.noLowering( + `${spelling} with bigint: ${literal} where the result is not ${literal === "true" ? "BigIntStats" : "Stats"}`, + value, + "pass bigint: true to receive BigIntStats, or omit it to receive Stats", + ); + } + const lowered = lowerer.lowerExprExpecting(value, BOOL); + return isSafeToDiscard(lowered) ? null : lowered; +} /** fs.statSync / fs.lstatSync(path, options). The options are an object - * literal, where throwIfNoEntry takes any boolean and bigint must be a - * literal false (BigIntStats has no lowering), or a stored record whose - * only member is a boolean throwIfNoEntry. When the call's type is - * `Stats | undefined`, the call lowers to the probe, which answers - * undefined for a missing entry without building the error. An explicitly - * undefined options argument is the one-argument form. */ + * literal, where throwIfNoEntry takes any boolean and bigint is the literal + * true (BigIntStats) or false (Stats), or a stored record whose only member + * is a boolean throwIfNoEntry. When the call's type is + * `Stats | undefined` (or `BigIntStats | undefined`), the call lowers to the + * probe, which answers undefined for a missing entry without building the + * error. An explicitly undefined options argument is the one-argument + * form. */ export function lowerFsStatOptionsCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1500,9 +1574,18 @@ export function lowerFsStatOptionsCall( const plainFn: IrLibFn = noFollow ? "fs.lstatSync" : "fs.statSync"; const optsNode = call.arguments.nodes[1]!; const path = lowerFsPath(lowerer, call.arguments.nodes[0]!); + // `{ bigint: true }` selects Node's BigIntStats: the SAME snapshot handle, + // read through the bigint member table. The checker's answer for the call + // already names the variant, so the mapped call type is the source of + // truth and the option member below has to agree with it. + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const def = callType?.kind === "union" ? lowerer.unions.get(callType.unionId) : undefined; + const representation = statRepresentation(callType, def?.arms); + const statsType: IrType = representation === "bigint" ? BIGINT_STATS_T : STATS_T; // The path binds first, then the options' effects, then the stat runs. const plainAfter = (effect: IrExpr | null): IrExpr => { - if (effect === null) return { kind: "libCall", fn: plainFn, args: [path], type: STATS_T, loc }; + if (effect === null) + return { kind: "libCall", fn: plainFn, args: [path], type: statsType, loc }; const pathLocal = lowerer.declareHiddenLocal("%statPath", STRING); return { kind: "seqExpr", @@ -1514,10 +1597,10 @@ export function lowerFsStatOptionsCall( kind: "libCall", fn: plainFn, args: [varRef(pathLocal.id, STRING, loc)], - type: STATS_T, + type: statsType, loc, }, - type: STATS_T, + type: statsType, loc, }; }; @@ -1579,6 +1662,10 @@ export function lowerFsStatOptionsCall( lowerer.noLowering(`${spelling} with this options value`, optsNode, "pass an object literal"); } } + // The members in source order, when a bigint member has an effect: the + // throwIfNoEntry value ("throwIfNoEntry") and each effect. + const members: (IrExpr | "throwIfNoEntry")[] = []; + let bigintEffect = false; for (const p of ts.isObjectLiteralExpression(optsNode) ? optsNode.properties.nodes : []) { const m = optionMember(p); if (!m) { @@ -1590,14 +1677,12 @@ export function lowerFsStatOptionsCall( } if (m.name === "throwIfNoEntry") { throwIfNoEntry = lowerer.lowerExprExpecting(m.value, BOOL); + members.push("throwIfNoEntry"); } else if (m.name === "bigint") { - const t = lowerer.typeOf(m.value); - if (!(t.flags & ts.TypeFlags.BooleanLiteral) || lowerer.checker.typeToString(t) !== "false") { - lowerer.noLowering( - `${spelling} with bigint: true`, - m.value, - "BigIntStats has no lowering; omit bigint for Stats", - ); + const effect = checkStatBigintOption(lowerer, m.value, spelling, representation); + if (effect !== null) { + members.push(effect); + bigintEffect = true; } } else { fenceOrDropOptionKey( @@ -1606,12 +1691,31 @@ export function lowerFsStatOptionsCall( m.name, spelling, FS_STAT_DOCUMENTED_OPTIONS, - "throwIfNoEntry and bigint: false are the supported options", + "throwIfNoEntry and bigint are the supported options", ); } } - const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); - if (callType?.kind === "stats") { + // With a bigint member's effect, the path binds first, then the members + // run in source order (a runtime throwIfNoEntry into a local), then the + // stat; without one, the call evaluates its operands in that order itself. + const ordered = (stat: (pathArg: IrExpr, flag: IrExpr | null) => IrExpr): IrExpr => { + if (!bigintEffect) return stat(path, throwIfNoEntry); + const pathLocal = lowerer.declareHiddenLocal("%statPath", STRING); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: pathLocal.id, init: path, loc: path.loc }]; + let flag = throwIfNoEntry; + for (const member of members) { + if (member !== "throwIfNoEntry") { + stmts.push({ kind: "exprStmt", expr: member, loc: member.loc }); + } else if (flag !== null && flag.kind !== "boolLit") { + const flagLocal = lowerer.declareHiddenLocal("%statThrowIfNoEntry", BOOL); + stmts.push({ kind: "varDecl", localId: flagLocal.id, init: flag, loc: flag.loc }); + flag = varRef(flagLocal.id, BOOL, flag.loc); + } + } + const result = stat(varRef(pathLocal.id, STRING, loc), flag); + return { kind: "seqExpr", stmts, result, type: result.type, loc }; + }; + if (callType?.kind === "stats" || callType?.kind === "bigintStats") { // A runtime flag that is false answers undefined, which a Stats result // cannot hold. if (throwIfNoEntry !== null && !(throwIfNoEntry.kind === "boolLit" && throwIfNoEntry.value)) { @@ -1621,13 +1725,19 @@ export function lowerFsStatOptionsCall( "the result must be Stats | undefined when throwIfNoEntry may be false", ); } - return plainAfter(null); + if (!bigintEffect) return plainAfter(null); + return ordered((pathArg) => ({ + kind: "libCall", + fn: plainFn, + args: [pathArg], + type: statsType, + loc, + })); } - const def = callType?.kind === "union" ? lowerer.unions.get(callType.unionId) : undefined; if ( !def || def.arms.length !== 2 || - !def.arms.some((a) => a.kind === "stats") || + !def.arms.some((a) => a.kind === "stats" || a.kind === "bigintStats") || !def.arms.some((a) => a.kind === "undefinedT") ) { lowerer.noLowering( @@ -1636,12 +1746,107 @@ export function lowerFsStatOptionsCall( `${spelling}(path, { throwIfNoEntry: false }) answers Stats | undefined`, ); } - return { + return ordered((pathArg, flag) => ({ kind: "libCall", fn: noFollow ? "fs.lstatProbeSync" : "fs.statProbeSync", - args: [path, throwIfNoEntry ?? boolLit(true, loc)], + args: [pathArg, flag ?? boolLit(true, loc)], type: callType!, loc, + })); +} + +/** fs.fstatSync(fd, options): the descriptor form of the same snapshot. + * Node documents only `bigint` here — an open descriptor cannot miss, so the + * result is Stats or BigIntStats, never undefined. */ +export function lowerFsStatFdOptionsCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const spelling = "fstatSync"; + const fd = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, F64); + const optsNode = call.arguments.nodes[1]!; + const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const representation = statRepresentation(callType, undefined); + const statsType: IrType = representation === "bigint" ? BIGINT_STATS_T : STATS_T; + const plain = (): IrExpr => ({ + kind: "libCall", + fn: "fs.fstatSync", + args: [fd], + type: statsType, + loc, + }); + const undefinedOptions = lowerStaticallyUndefinedArgument(lowerer, optsNode); + if (undefinedOptions !== null) { + if (isSafeToDiscard(undefinedOptions)) return plain(); + // The descriptor binds first, then the options' effects, then the stat. + const fdLocal = lowerer.declareHiddenLocal("%fstatFd", F64); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: fdLocal.id, init: fd, loc: fd.loc }, + { kind: "exprStmt", expr: undefinedOptions, loc: undefinedOptions.loc }, + ], + result: { + kind: "libCall", + fn: "fs.fstatSync", + args: [varRef(fdLocal.id, F64, loc)], + type: statsType, + loc, + }, + type: statsType, + loc, + }; + } + if (!ts.isObjectLiteralExpression(optsNode)) { + lowerer.noLowering( + `${spelling} with this options value`, + optsNode, + `pass an object literal: ${spelling}(fd, { bigint: true })`, + ); + } + const effects: IrExpr[] = []; + for (const p of optsNode.properties.nodes) { + const m = optionMember(p); + if (!m) { + lowerer.noLowering( + `${spelling} with this options shape`, + p, + "spreads and computed keys have no lowering — write each member inline", + ); + } + if (m.name === "bigint") { + const effect = checkStatBigintOption(lowerer, m.value, spelling, representation); + if (effect !== null) effects.push(effect); + } else { + fenceOrDropOptionKey( + lowerer, + p, + m.name, + spelling, + FS_FSTAT_DOCUMENTED_OPTIONS, + "bigint is the supported option", + ); + } + } + if (effects.length === 0) return plain(); + // The descriptor binds first, then the bigint member's effect, then the stat. + const fdLocal = lowerer.declareHiddenLocal("%fstatFd", F64); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: fdLocal.id, init: fd, loc: fd.loc }, + ...effects.map((expr): IrStmt => ({ kind: "exprStmt", expr, loc: expr.loc })), + ], + result: { + kind: "libCall", + fn: "fs.fstatSync", + args: [varRef(fdLocal.id, F64, loc)], + type: statsType, + loc, + }, + type: statsType, + loc, }; } diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index bf5223d2f..cd9db34ab 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -38,7 +38,12 @@ import { type ParamShape } from "../call-signatures.js"; import { BYTES_U8, DYN, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import { lowerProcessLoadEnvFile } from "./environment.js"; -import { lowerFsWatchCall, lowerFsReaddirTypesCall, lowerFsStatOptionsCall } from "./filesystem.js"; +import { + lowerFsWatchCall, + lowerFsReaddirTypesCall, + lowerFsStatFdOptionsCall, + lowerFsStatOptionsCall, +} from "./filesystem.js"; import { lowerZlibModuleCall } from "./compression.js"; import { lowerForkCall } from "./child-process.js"; import { @@ -162,8 +167,8 @@ export function lowerBuiltinModuleCall( if ((syncReaddirTypes || promiseReaddirTypes) && expr.arguments.nodes.length === 2) { return lowerFsReaddirTypesCall(lowerer, expr, loc, promiseReaddirTypes); } - // fs.statSync/fs.lstatSync(path, options): throwIfNoEntry and bigint: - // false, routed BEFORE the 1-arg table completion. + // fs.statSync/fs.lstatSync(path, options) and fs.fstatSync(fd, options): + // throwIfNoEntry and bigint, routed BEFORE the table completion. if ( bi.module === "fs" && (bi.member === "statSync" || bi.member === "lstatSync") && @@ -171,6 +176,9 @@ export function lowerBuiltinModuleCall( ) { return lowerFsStatOptionsCall(lowerer, expr, loc, bi.member === "lstatSync"); } + if (bi.module === "fs" && bi.member === "fstatSync" && expr.arguments.nodes.length === 2) { + return lowerFsStatFdOptionsCall(lowerer, expr, loc); + } if (bi.module === "os" && bi.member === "userInfo") { return lowerOsUserInfoCall(lowerer, expr, loc); } diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 3211994b9..2b2a05208 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -849,6 +849,11 @@ export function lowerProcessProperty( "process.env as a value of this type (read one variable: process.env.NAME or process.env[name])", ); } + // process.cwd as a value: the process module's member (surfaces.ts). + if (member === "cwd") { + const value = lowerer.lowerBuiltinCallableValue({ module: "process", member }, loc); + if (value) return value; + } if (member === "exit" || member === "cwd" || member === "getuid" || member === "kill") { lowerer.unsupported("SC1090", expr, `process methods as values (call '${member}' directly)`); } diff --git a/packages/compiler/src/frontend/lowering/builtins/properties.ts b/packages/compiler/src/frontend/lowering/builtins/properties.ts index 70512fed9..063fa05be 100644 --- a/packages/compiler/src/frontend/lowering/builtins/properties.ts +++ b/packages/compiler/src/frontend/lowering/builtins/properties.ts @@ -5,6 +5,7 @@ import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { isChildSurfaceMember } from "../surfaces.js"; import { + BIGINT_T, BOOL, CHILDSTREAM_T, CHILDWRITER_T, @@ -49,6 +50,22 @@ const STATS_FIELDS: Record = { birthtime: "stats.birthtime", }; const DATE_FIELDS = new Set(["atime", "mtime", "ctime", "birthtime"]); +/** BigIntStats answers a `bigint` for EVERY number member of Stats — the + * integers, the `*Ms` millisecond members (the truncated nanosecond + * quotient, as Node's binding computes them) and the BigIntStats-only `*Ns` + * nanosecond members. Only the four time members stay `Date`s. */ +const NANOSECOND_FIELDS: Record = { + atimeNs: "bigstats.atimeNs", + mtimeNs: "bigstats.mtimeNs", + ctimeNs: "bigstats.ctimeNs", + birthtimeNs: "bigstats.birthtimeNs", +}; +const BIGINT_STATS_FIELDS: Record = { + ...Object.fromEntries( + Object.entries(STATS_FIELDS).map(([name]) => [name, `bigstats.${name}` as IrLibFn]), + ), + ...NANOSECOND_FIELDS, +}; export function lowerBuiltinExtraProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, @@ -136,6 +153,7 @@ export function lowerBuiltinExtraProperty( } if ( kind !== "stats" && + kind !== "bigintStats" && kind !== "fileHandle" && kind !== "dir" && kind !== "spawnRes" && @@ -260,11 +278,15 @@ export function lowerBuiltinExtraProperty( lowerer.checker.getSymbolAtLocation(expr.name), ); } - if (kind === "stats" && Object.hasOwn(STATS_FIELDS, name)) { + const statsFields = + kind === "stats" ? STATS_FIELDS : kind === "bigintStats" ? BIGINT_STATS_FIELDS : undefined; + if (statsFields !== undefined && Object.hasOwn(statsFields, name)) { const receiver = lowerer.lowerExpr(expr.expression); - const fn = STATS_FIELDS[name]!; - // The Date fields are the *Ms values rounded, as Node's dateFromMs builds them. - const type = DATE_FIELDS.has(name) ? DATE_T : F64; + const fn = statsFields[name]!; + // The Date fields are the millisecond value rounded, as Node's + // dateFromMs builds them; every other Stats number is a bigint on + // BigIntStats. + const type = DATE_FIELDS.has(name) ? DATE_T : kind === "bigintStats" ? BIGINT_T : F64; return { kind: "libCall", fn, args: [receiver], type, loc }; } if (kind === "spawnRes" && name === "signal") { diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 6d1e97f8c..45329d077 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -20,7 +20,7 @@ import { isUnitType, typeEquals, } from "../../ir/ir.js"; -import { isJsSourceFile, isNodeEsmFile } from "../program.js"; +import { isJsSourceFile, isNodeEsmFile, locOf } from "../program.js"; import { genResultRecord, restTupleParamTypes, @@ -36,7 +36,7 @@ import { isStreamUndefCallExpr } from "./lower-server.js"; import { nsPathPrefix } from "./lower-namespaces.js"; import { declSymbolOf } from "./lower-modules.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; -import { restPackingAdapterFits } from "./coercions/function-adapters.js"; +import { funcSlotAdaptable, restPackingAdapterFits } from "./coercions/function-adapters.js"; import { implicitMonoFile, implicitAnyParamSymbolsOf } from "./generic-functions.js"; /** How a parameter participates in CALL-SITE COMPLETION (the frontend @@ -668,21 +668,6 @@ export function requireExactArityValue( // optional/defaulted params spell their `T | undefined` slots (mapType's // completed-signature contract), so omitted trailing args complete with // the undefined arm like any direct call. - // A nested function declaration whose only non-required parameter is a - // typed rest: its value carries the typed-rest ABI its own type spells, - // and every use converts it at its slot (same signature, or the rest - // packing adapter for fixed parameters). - if ( - contextual === null && - ts.isFunctionDeclaration(blame) && - blame.name !== undefined && - funcType.kind === "func" && - funcType.restAbi === "typed" && - shapes.every((s) => s.mode === "required" || s.mode === "rest") - ) { - const own = lowerer.mapTypeOf(lowerer.typeOf(blame.name)); - if (own && typeEquals(own, funcType)) return; - } const target = contextual ? lowerer.checker.getContextualType(contextual) : undefined; const mapped = target ? lowerer.mapTypeOf(target) @@ -722,8 +707,33 @@ export function requireExactArityValue( const member = literalType ? lowerer.checker.getPropertyOfType(literalType, blame.name.text) : undefined; - const slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null; + let slot = member ? lowerer.memberSlotTypeOf(lowerer.checker.getTypeOfSymbol(member)) : null; + // A literal contextualized by an INDEX SIGNATURE (`Record`) names no member for the method: its slot is the index + // signature's value type. + if (!slot && literalType) { + const index = lowerer.checker.getIndexInfosOfType(literalType)[0]; + if (index) slot = lowerer.memberSlotTypeOf(index.type); + } if (slot && typeEquals(slot, funcType)) return; + // The slot may pass parameters the method's completed ABI cannot take + // verbatim (a required narrower interface parameter where the method + // declares `ctx?: Ctx`, completing to `Ctx | undefined`): the slot + // coercion adapts the pair like any other function value. + if ( + slot?.kind === "func" && + funcType.kind === "func" && + funcSlotAdaptable(lowerer, funcType, slot, locOf(blame)) + ) + return; + // An UNCONTEXTUALIZED literal's own inferred type is what every later + // read of the member spells, and it is built from the method's own + // completed signature (mapType's completed-signature contract), so the + // method's own completed type is its slot. + if (!slot) { + const own = lowerer.mapTypeOf(lowerer.typeOf(blame)); + if (own && typeEquals(own, funcType)) return; + } } // A union-typed slot (`runner || defaultRunner` under a // `CommandRunner | undefined` context): the value can only inhabit @@ -770,6 +780,18 @@ export function requireExactArityValue( ) { return; } + // A signature the slot's coercion adapts without a trap-only path: an + // extra trailing OPTIONAL parameter the slot never passes (forwarded as + // undefined, JavaScript's surplus-argument rule) or a parameter pair the + // adapter converts (a required narrower interface parameter against the + // value's `Ctx | undefined` completed slot). + if ( + mappedFn?.kind === "func" && + funcType.kind === "func" && + funcSlotAdaptable(lowerer, funcType, mappedFn, locOf(blame)) + ) { + return; + } // An uncontextualized npm-static implementation function can still // have a declaration-backed completed ABI: the package's .d.ts // projection supplies the body-facing parameter/return types while diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index f29eaff3a..76a9b7f95 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -37,7 +37,19 @@ export class ClassProtocols { }; const usesReceiver = (node: ts.Node): boolean => node.kind === ts.SyntaxKind.ThisKeyword || ts.forEachChild(node, usesReceiver) === true; + // The names any type in the program declares as a get/set accessor, and + // every object literal in it. A data member of a literal can only + // disagree with a target at a name in this set, and marking waits until + // the walk ends, so the checker round trip stays off every literal whose + // names are not accessors somewhere (this analysis runs on every + // program, so it must stay syntax-cheap). + const accessorNames = new Set(); + const literals: ts.ObjectLiteralExpression[] = []; const visit = (node: ts.Node): void => { + if (ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + const name = propertyNameText(node.name); + if (name !== null) accessorNames.add(name); + } if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { for (const clause of node.heritageClauses?.nodes ?? []) if (clause.token === ts.SyntaxKind.ImplementsKeyword) @@ -61,31 +73,107 @@ export class ClassProtocols { this.methods.set(ts.symbolName(property), entries); } } - if ( - ts.isObjectLiteralExpression(node) && - node.properties.nodes.some( - (property) => - (ts.isMethodDeclaration(property) || - ts.isGetAccessorDeclaration(property) || - ts.isSetAccessorDeclaration(property)) && - property.body !== undefined && - usesReceiver(property.body), - ) - ) { - // Receiver-dependent methods and accessors need a live object even - // without a class implementation. Receiver-free methods keep their - // native callable fields, including async callbacks. - add(this.checker.getTypeAtLocation(node)); - const contextual = this.checker.getContextualType(node); - if (contextual) add(contextual); + if (ts.isObjectLiteralExpression(node)) { + if ( + node.properties.nodes.some( + (property) => + (ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSetAccessorDeclaration(property)) && + property.body !== undefined && + usesReceiver(property.body), + ) + ) { + // Receiver-dependent methods and accessors need a live object even + // without a class implementation. Receiver-free methods keep their + // native callable fields, including async callbacks. + add(this.checker.getTypeAtLocation(node)); + const contextual = this.checker.getContextualType(node); + if (contextual) add(contextual); + } + // Member-kind disagreements with the literal's target type are + // checked once the walk ends, when the accessor-name index above is + // complete (see markMemberKindViews). + literals.push(node); } ts.forEachChild(node, visit); }; for (const source of this.sources) if (!source.isDeclarationFile) visit(source); + for (const literal of literals) this.markMemberKindViews(literal, accessorNames, add); this.markIdentityFlows(protocols); return protocols; } + /** Marks the structural types a literal's member kinds disagree with: a + * get/set accessor satisfying a declared DATA member, or a data property + * satisfying a declared accessor member. One static record layout cannot + * hold both member kinds at one name (a data member is a storage slot, an + * accessor member a `%get:`/`%set:` closure pair), so the target type + * keeps checked identity instead: every value of it is a live + * checked-dynamic view, where the accessor stays live, `Object.keys` + * lists it, and `JSON.stringify` calls it — Node's own model. + * + * Only the TARGET is marked: the literal then takes the checked-dynamic + * literal path (`lowerDynObjectLiteral`), which defines an accessor member + * as a live get/set property and a data member as a plain property, so + * both directions construct live objects. */ + private markMemberKindViews( + literal: ts.ObjectLiteralExpression, + accessorNames: ReadonlySet, + add: (type: ts.Type) => void, + ): void { + const members: { name: string; accessor: boolean }[] = []; + for (const property of literal.properties.nodes) { + if (ts.isSpreadAssignment(property) || property.name === undefined) continue; + const name = propertyNameText(property.name); + if (name === null) continue; + if (ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property)) { + members.push({ name, accessor: true }); + continue; + } + if ( + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + // A data member only disagrees with an accessor-declared target, and + // only a type that declares an accessor somewhere can be one: the + // name index keeps the checker round trip off the common literal. + accessorNames.has(name) + ) + members.push({ name, accessor: false }); + } + if (members.length === 0) return; + const contextual = attempt(() => this.checker.getContextualType(literal)); + if (contextual === undefined) return; + // Only one object type can be the one the literal converts into: a union + // with several arms picks its arm from the value itself, and an + // intersection merges members, so neither has the single layout the + // literal would have to disagree with. Nullish arms are ignored, so + // `Counter | undefined` still names Counter. + const present = contextual.isUnionType() + ? ts + .constituentTypes(contextual) + .filter((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0) + : [contextual]; + const target = present.length === 1 ? present[0]! : undefined; + if (target === undefined || target.isIntersectionType()) return; + if ((target.flags & ts.TypeFlags.TypeParameter) !== 0) return; + if (!this.isStructuralTarget(target)) return; + const disagrees = members.some((member) => { + const kind = attempt(() => this.memberKind(target, member.name)); + return kind == null ? false : (kind === "accessor") !== member.accessor; + }); + if (disagrees) add(target); + } + + /** The member kind `type` declares at `name` — an accessor (get/set pair) + * or a data property — or null when it declares no member by that name. */ + private memberKind(type: ts.Type, name: string): "accessor" | "data" | null { + const property = this.checker.getPropertyOfType(type, name); + if (property === undefined) return null; + return property.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor) + ? "accessor" + : "data"; + } + /** Structural types that receive a value with identity: a program class * instance, or a value of a type already marked here. Natively such a * type would be a record, and the conversion into it a field copy, so a @@ -1375,6 +1463,14 @@ function attempt(query: () => T): T | undefined { } } +/** The static property name a member declares, or null for a computed key + * (its name is not a compile-time fact). */ +function propertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text; + if (ts.isNumericLiteral(name)) return String(Number(name.text)); + return null; +} + /** `(value as T).member` and `(value as T)[key]`: the assertion is read or * written member-wise in place, which lowers to the instance's own fields * and creates no view that could outlive it. */ diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 78c273b66..44bca35e1 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -134,6 +134,57 @@ export function cleanFuncAdaptable( return src.ret.kind === "void" && dst.ret.kind === "dyn"; } +/** Whether the slot's argument type `actual` converts into the function's + * parameter type `expected` without a trap-only path: the pieces + * funcCoerceAdapter's parameter loop converts (coercibleValue, the record + * width conversion — which also selects a width-liftable arm of a union + * destination, the required-narrower-argument shape — the partial + * checked-dynamic extraction, and the union arm preservation). */ +function cleanParamPair(lowerer: Lowerer, actual: IrType, expected: IrType): boolean { + if (lowerer.coercibleValue(actual, expected)) return true; + if (lowerer.widthLiftPlan(actual, expected) !== null) return true; + if (actual.kind === "union" && expected.kind === "dyn") + return ( + lowerer.unions.get(actual.unionId)?.arms.some((arm) => lowerer.dynConvertible(arm)) ?? false + ); + if (actual.kind === "union" && expected.kind === "union") { + const target = lowerer.unions.get(expected.unionId); + return ( + target !== undefined && target.arms.every((arm) => lowerer.armTag(actual.unionId, arm) >= 0) + ); + } + return false; +} + +/** Whether a function VALUE of `src` may inhabit a `dst` slot: the slot's + * coercion (funcCoerceAdapter) forwards its arguments, converts them, and + * converts the result — with no trap-only (stranded) path. This is + * cleanFuncAdaptable extended to the source-with-MORE-parameters shape: a + * value carrying extra trailing parameters whose completed ABI holds + * undefined (a `(id, params, signal, onUpdate, ctx?: Ctx)` lambda in a + * four-parameter slot). A call through the slot never passes them, so the + * coercion forwards undefined — JavaScript's surplus-argument rule. */ +export function funcSlotAdaptable( + lowerer: Lowerer, + src: IrType & { kind: "func" }, + dst: IrType & { kind: "func" }, + loc: SrcLoc, +): boolean { + if (restPackingAdapterFits(lowerer, src, dst)) return true; + if (src.rest === true || dst.rest === true) return false; + for (let i = dst.params.length; i < src.params.length; i++) { + const param = src.params[i]!; + if (omittedArgFor(lowerer, param, loc) === null && !typeEquals(param, UNDEFINED_T)) + return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { + if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false; + } + if (dst.ret.kind === "void") return true; + if (lowerer.coercibleValue(src.ret, dst.ret)) return true; + return src.ret.kind === "void" && dst.ret.kind === "dyn"; +} + /** Whether a typed-rest function (`(...m: T[]) => R`) can serve a slot * with fixed parameters (`(m: T) => void`): the slot's parameters convert * to the function's fixed parameters, then to the rest element type. */ @@ -348,6 +399,7 @@ export function funcCoerceAdapter( continue; } } + if (cleanParamPair(lowerer, actual, expected)) continue; strandParams = true; } let voidRet: "dyn" | "strand" | null = null; 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 3934edbf0..ba8fb9d53 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. @@ -229,10 +231,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 } @@ -249,8 +276,11 @@ function unionConversion( loc, }); } - // Every arm the target names must be one of the union's own arms. - if (kept === 0 || kept !== targetArms.length) return null; + // 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. + 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 58ae3e7bd..e20604f92 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/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..ebe7e2556 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -86,7 +86,7 @@ import { identifierReadSymbol, stdlibGlobalNameOf, } from "../surfaces.js"; -import { builtinConstantsObject } from "../lower-builtin-values.js"; +import { builtinConstantsObject, pathModuleValue } from "../lower-builtin-values.js"; import { lowerTlsRootCertificates } from "../lower-server.js"; import { builtinFunctionFenceValue } from "../builtin-function-fences.js"; import { unsupportedModuleFeatureOf } from "../../builtin-modules.js"; @@ -562,6 +562,16 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: } const c = builtinModuleConstOf(lowerer, bi.module, bi.member); if (c !== undefined) return builtinConstLit(c, loc); + // `import { posix, win32 } from "node:path"` as VALUES: the platform + // namespaces are members of the path module object, so the named + // import reads the same checked-dynamic module value the property + // form (path.posix) lowers to. + if ( + (bi.module === "path" || bi.module === "path/posix" || bi.module === "path/win32") && + (bi.member === "posix" || bi.member === "win32") + ) { + return pathModuleValue(lowerer, `path/${bi.member}`, loc); + } // module.builtinModules — the baked Node v24 list, a fresh // string[] per read. if (bi.module === "module" && bi.member === "builtinModules") { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 9699a4fb9..a3d2a5b11 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1,7 +1,7 @@ import { keyedHelperName } from "../stable-names.js"; import { dateJsonType, isDateJsonSource } from "../builtins/dates.js"; import { dynUndefinedExpr, numLit, strLit, varRef } from "../../../ir/build.js"; -import { pathModuleValue } from "../lower-builtin-values.js"; +import { pathModuleSpreadSource } from "../lower-builtin-values.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; import { @@ -20,7 +20,10 @@ import { import type { IrExpr, IrLocal, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; import { isCjsExportTableLiteral, isJsSourceFile, locOf } from "../../program.js"; import { NARROW_FIRST } from "../surfaces.js"; -import { recordShapeMismatchDiag } from "../../../diagnostics/diagnostic.js"; +import { + recordShapeMismatchDiag, + unionLiteralMismatchDiag, +} from "../../../diagnostics/diagnostic.js"; import { PoisonError, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js"; import type { Lowerer } from "../lowerer.js"; import { lowerIndexMergeHelper } from "../containers/indexed-objects.js"; @@ -307,7 +310,12 @@ export function lowerDynObjectLiteral( const builtin = lowerer.builtinNamespaceModuleOf(prop.expression); const raw = builtin === "path" || builtin === "path/posix" || builtin === "path/win32" - ? pathModuleValue(lowerer, builtin === "path" ? "path/posix" : builtin, locOf(prop)) + ? pathModuleSpreadSource( + lowerer, + builtin === "path" ? "path/posix" : builtin, + isNamespaceImport(lowerer, prop.expression), + locOf(prop), + ) : !boxValue && conditionalArms && (conditionalSpreadOf(prop.expression) !== null || @@ -1301,7 +1309,24 @@ export function lowerObjectLiteral( if (fallback) return fallback; lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } - if (!mapped || mapped.kind !== "record") lowerer.badType(expr, tsType); + if (!mapped || mapped.kind !== "record") { + // A spread merge over UNION-typed sources (`{ ...base, ...override }` + // with `base`, `override` of arm-union types) has one field set per + // combination of source arms, so its own type is a union of records. + // No single arm of the contextual union can hold that value without + // dropping the fields the other arms contributed (Node keeps them + // all), and a runtime tag would be a guess about which arm the value + // "is". Refuse, naming the candidate arms. + if (mapped?.kind === "union") { + const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType]; + const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))]; + lowerer.pushDiag( + unionLiteralMismatchDiag(lowerer.checker.typeToString(tsType), arms, locOf(expr)), + ); + throw new PoisonError(); + } + lowerer.badType(expr, tsType); + } let type: IrType = mapped; // A fresh, plain literal can construct its fields directly for a known // destination with the same field set. This matters for inferred @@ -3655,3 +3680,12 @@ function rejectThisInObjectAccessor(lowerer: Lowerer, node: ts.Node): void { } ts.forEachChild(node, (child) => rejectThisInObjectAccessor(lowerer, child)); } + +/** Whether a spread source is an ES module namespace binding + * (`import * as path`); a default import, a require and a named + * sub-namespace are module objects. */ +function isNamespaceImport(lowerer: Lowerer, expr: ts.Expression): boolean { + if (!ts.isIdentifier(expr)) return false; + const decl = lowerer.checker.getSymbolAtLocation(expr)?.declarations[0]; + return decl !== undefined && ts.isNamespaceImport(decl); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..3bcaf1013 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -24,7 +24,14 @@ import { localPrivateBrandClass, privateNameClass, } from "../private-brands.js"; -import { type Lowerer, PoisonError, jsFuncNameOf, neverTaintedJsType, own } from "../lowerer.js"; +import { + type Lowerer, + PoisonError, + builtinValueNameOf, + jsFuncNameOf, + neverTaintedJsType, + own, +} from "../lowerer.js"; import { ARRAY_METHODS, MAP_METHODS, @@ -44,6 +51,7 @@ import { isParseArgsDynTypeName, } from "../../type-mapper.js"; import { + BIGINT_T, BOOL, BYTES_U8, DYN, @@ -1202,13 +1210,15 @@ export function lowerPropertyAccessExpr( (id) => lowerer.unions.get(id), ) ) { - const fnName = jsFuncNameOf(expr.expression); - // A builtin function value whose length is not its adapter's - // parameter count (`Math.max.length` is 2). - const fnLength = - probed.kind === "closure" - ? lowerer.builtinCallableValueLengths.get(probed.fnName) - : undefined; + // A function is named by its definition, through const aliases: a + // builtin module member whose Node name differs from the member + // (`process.cwd` is `wrappedCwd`) by the module table, anything else + // by the property or function it reads. A builtin function value + // keeps its length when that is not its adapter's parameter count + // (`Math.max.length` is 2). + const fnName = + builtinValueNameOf(expr.expression, lowerer) ?? jsFuncNameOf(expr.expression, lowerer); + const fnLength = lowerer.builtinFunctionIdentity(probed).fnLength; const boxed: IrExpr = { kind: "dynFrom", value: probed, @@ -2097,6 +2107,7 @@ export function lowerIntrinsicProperty( kind !== "url" && kind !== "searchParams" && kind !== "stats" && + kind !== "bigintStats" && kind !== "spawnRes" && kind !== "child" && kind !== "bytes" && @@ -2252,18 +2263,31 @@ export function lowerIntrinsicProperty( lowerer.checker.getSymbolAtLocation(expr.name), ); } - if (kind === "stats") { + if (kind === "stats" || kind === "bigintStats") { + const bigintStats = kind === "bigintStats"; if (name === "size") { const receiver = lowerer.lowerExpr(expr.expression); - return { kind: "libCall", fn: "stats.size", args: [receiver], type: F64, loc: locOf(expr) }; + return { + kind: "libCall", + fn: bigintStats ? "bigstats.size" : "stats.size", + args: [receiver], + type: bigintStats ? BIGINT_T : F64, + loc: locOf(expr), + }; } if (Object.hasOwn(STATS_TYPE_TESTS, name)) { - lowerer.unsupported("SC1090", expr, `Stats methods as values (call '${name}' directly)`); + lowerer.unsupported( + "SC1090", + expr, + `${bigintStats ? "BigIntStats" : "Stats"} methods as values (call '${name}' directly)`, + ); } lowerer.noLowering( - `Stats.${name}`, + `${bigintStats ? "BigIntStats" : "Stats"}.${name}`, expr, - "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", + bigintStats + ? "BigIntStats' isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeNs, mtimeNs, ctimeNs, birthtimeNs, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported members" + : "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", lowerer.checker.getSymbolAtLocation(expr.name), ); } @@ -3474,6 +3498,26 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi return null; } +/** Whether a receiver is a value the checker ruled UNREACHABLE (`never`), + * directly or through the parenthesized assertion chain that escapes it + * (`(x as any)`, `(x as unknown)`, `x!`). The erasure is what hides the + * value's stored union from the checker; the value itself still holds it. */ +function unreachableReceiver(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + let node: ts.Expression = expr.expression; + for (;;) { + if ((lowerer.typeOf(node).flags & ts.TypeFlags.Never) !== 0) return true; + if ( + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) || + ts.isAssertionExpression(node) + ) { + node = node.expression; + continue; + } + return false; + } +} + /** Shared-field read `r.f` on a UNION receiver: supported exactly when * every arm is a record/class possessing the field with ONE shared IR * type — the discriminant pattern (`r.kind`, primitive) and the @@ -3491,14 +3535,26 @@ export function lowerUnionProperty( const receiverIr = structuralReadType(lowerer, expr.expression) ?? lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (receiverIr?.kind !== "union") return null; + // The checker can rule the receiver UNREACHABLE and the assertion that + // escapes it (`const x: never = event; (event as any).type` — the + // `default:` branch of a switch that exhausts every arm) erases to + // checked-dynamic, hiding the union the value actually holds. Node reads + // the member off the object it is; the arms all declare it, so the read + // is the shared-field read. `never` admits no reachable call site, so no + // reachable program changes shape. + const unreachable = receiverIr?.kind !== "union" && unreachableReceiver(lowerer, expr); + if (receiverIr?.kind !== "union" && !unreachable) return null; // Lower the receiver first: its runtime value can still carry the full // binding union even when the checker has removed several variants. let value = lowerer.lowerExpr(expr.expression); // A guard can remove several variants without selecting one record. // Materialize that sub-union before looking for common fields; arms // ruled out by the guard must not veto a field shared by the survivors. - if (value.type.kind === "union" && !typeEquals(value.type, receiverIr)) { + if ( + receiverIr?.kind === "union" && + value.type.kind === "union" && + !typeEquals(value.type, receiverIr) + ) { const helper = lowerer.narrowedRetagHelper( expr.expression, value.type.unionId, diff --git a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts index 66cd9c42e..8f24f3a90 100644 --- a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts +++ b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts @@ -306,6 +306,83 @@ export function lowerUnionFieldWrite( return { kind: "seqExpr", stmts: body, result: dispatch, type: result.type, loc }; } +/** `delete u.f` on a union receiver: each record arm declares `f` as an + * OPTIONAL field (an undefined-armed slot), so each tag writes its own + * slot's ABSENT state — the monomorphic optional-field delete, dispatched + * over the arms the way union field writes are. The union value is + * retained once, so every arm deletes the object the program passed, not a + * projection. Null when an arm is not a record that declares `f` optional + * (unit arms, class arms, required fields, accessors and index signatures + * keep their own fences): a required slot has no absence to represent. */ +export function lowerUnionFieldDelete( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): IrStmt | null { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + if (access.questionDotToken || mapped?.kind !== "union") return null; + let receiver = lowerer.maybeNarrow(lowerer.lowerExpr(access.expression), access.expression); + if (receiver.type.kind === "union" && !typeEquals(receiver.type, mapped)) + receiver = narrowStoredClassValue(lowerer, receiver, mapped) ?? receiver; + if (receiver.type.kind !== "union") return null; + const unionId = receiver.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if (!arms || arms.length < 2) return null; + const name = access.name.text; + const loc = locOf(access); + const fields: { arm: IrType & { kind: "record" }; type: IrType }[] = []; + for (const arm of arms) { + if (arm.kind !== "record" || lowerer.shapes.get(arm.shapeId)?.tuple) return null; + const field = unionFieldArm(lowerer, arm, name); + if (!field || lowerer.absentFieldValue(field.type, loc) === null) return null; + fields.push({ arm, type: field.type }); + } + const object = lowerer.declareHiddenLocal("%unionDeleteObject", receiver.type); + const objectRef = varRef(object.id, object.type, loc); + const branch = (tag: number): IrStmt[] => { + const field = fields[tag]!; + const absent = lowerer.absentFieldValue(field.type, loc); + if (absent === null) return []; + const write: IrStmt = { + kind: "recordSet", + obj: { kind: "unionNarrow", unionId, tag, value: objectRef, type: field.arm, loc }, + shapeId: field.arm.shapeId, + field: name, + value: absent, + loc, + }; + // A sloppy-mode delete on a frozen record leaves it unchanged. + markSloppyRecordWrites(lowerer, write, access); + return [write]; + }; + // One tag test per arm, the last arm as the fall-through else: a delete + // runs at most one arm's write. + const dispatch = (tag: number): IrStmt[] => + tag === fields.length - 1 + ? branch(tag) + : [ + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: objectRef, + negated: false, + type: BOOL, + loc, + }, + then: branch(tag), + else_: dispatch(tag + 1), + loc, + }, + ]; + return { + kind: "block", + body: [{ kind: "varDecl", localId: object.id, init: receiver, loc }, ...dispatch(0)], + loc, + }; +} + /** Read the element at a literal index from a union of tuples whose element * types differ (`readonly [true, number] | readonly [false, string]`, index * 1): each tag reads its own tuple's slot and widens it into the union of diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 3700b57ff..75056b3cc 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -121,6 +121,8 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; + const checked: IrType[] = []; + let freezesUntyped = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -130,19 +132,28 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true; + else if (e.kind === "dynFrom") live.push(e.value.type); + else if (e.kind === "dynCheck") checked.push(e.type); return true; }, stmt: () => true, }); - if (!lowerer.untypedIntegrity) return { shapes, arrays }; - // An untyped reference to a class instance, or to a record or array - // boxed live, reaches the values its fields and elements hold as further - // live references. Class instances also cross into untyped code through - // compiled callbacks, so every class is a root. - const stack = [...live]; - for (const info of lowerer.classes.values()) - for (const field of info.def.fields) stack.push(field.type); + if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; + const stack: IrType[] = []; + // A record checked from an untyped value copies it, frozen when the value + // is (a fresh literal frozen in a JavaScript file included), so the shapes + // a check builds are freezable in a program that freezes untyped values. + if (freezesUntyped) stack.push(...checked); + if (lowerer.untypedIntegrity) { + // An untyped reference to a class instance, or to a record or array + // boxed live, reaches the values its fields and elements hold as further + // live references. Class instances also cross into untyped code through + // compiled callbacks, so every class is a root. + stack.push(...live); + for (const info of lowerer.classes.values()) + for (const field of info.def.fields) stack.push(field.type); + } const seen = new Set(); while (stack.length > 0) { const type = stack.pop()!; diff --git a/packages/compiler/src/frontend/lowering/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/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 91f705fef..b5d5a7225 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -25,6 +25,7 @@ import { type SrcLoc, } from "../../ir/ir.js"; import { isJsSourceFile } from "../program.js"; +import { isStrictCode } from "./strict-mode.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; import { stdlibGlobalNameOf } from "./surfaces.js"; @@ -122,11 +123,17 @@ function readsBuiltinByName(lowerer: Lowerer, node: ts.ElementAccessExpression): /** The value a JavaScript source reads for the declared global `name` * taken as a value, whatever its spelling (`name`, `globalThis.name`, * `const { name } = globalThis`, `globalThis[name]`), or null when it - * reads the opaque identity token. */ -export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + * reads the opaque identity token. `reportFences` (builtinNamespaceValue) + * is false for the program's lookup. */ +export function jsGlobalValueByName( + lowerer: Lowerer, + name: string, + loc: SrcLoc, + reportFences = true, +): IrExpr | null { return ( jsSpecialGlobalValue(lowerer, name, loc) ?? - builtinNamespaceValue(lowerer, name, loc) ?? + builtinNamespaceValue(lowerer, name, loc, reportFences) ?? builtinGlobalValue(name, loc, true) ); } @@ -169,22 +176,150 @@ const URI_FUNCTIONS = new Set([ "decodeURIComponent", ]); +/** The builtin globals that sloppy-mode functions of the JavaScript source + * `sf` read through their `this` (`this.Array`, `this["Array"]`) or through + * a variable it initializes (`var root = this; root.Array`); every declared + * global when such a read takes a computed key (`this[name]`). Called with + * undefined or null as `this`, such a function reads them on the global + * object, which answers them through the program's lookup. */ +export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): ReadonlySet { + const cached = lowerer.sloppyThisGlobalReads.get(sf); + if (cached !== undefined) return cached; + const reads = new Set(); + if (isJsSourceFile(sf)) { + const declared = new Set(declaredGlobalNames(lowerer, sf)); + // Element reads of `this` or of a variable, and the names of the + // variables initialized with `this`: the checker resolves only the + // element reads of `this` and of those variables. + const elementReads: ts.ElementAccessExpression[] = []; + const thisAliases = new Set(); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.name) && + declared.has(node.name.text) && + readsSloppyThis(lowerer, node.expression) + ) + reads.add(node.name.text); + else if (ts.isElementAccessExpression(node)) { + const target = withoutParentheses(node.expression); + if (target.kind === ts.SyntaxKind.ThisKeyword || ts.isIdentifier(target)) + elementReads.push(node); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer !== undefined && + withoutParentheses(node.initializer).kind === ts.SyntaxKind.ThisKeyword + ) + thisAliases.add(node.name.text); + return undefined; + }); + for (const node of elementReads) { + const target = withoutParentheses(node.expression); + if (ts.isIdentifier(target) && !thisAliases.has(target.text)) continue; + if (!readsSloppyThis(lowerer, node.expression) || !readsBuiltinByName(lowerer, node)) + continue; + const key = withoutParentheses(node.argumentExpression); + if (ts.isStringLiteralLike(key)) { + if (declared.has(key.text)) reads.add(key.text); + } else if (!ts.isNumericLiteral(key)) { + for (const name of declared) reads.add(name); + break; + } + } + } + lowerer.sloppyThisGlobalReads.set(sf, reads); + return reads; +} + +/** Whether `expr` is the `this` of a sloppy-mode function, or a variable + * whose only declaration initializes it with that `this`. */ +function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { + const inner = withoutParentheses(expr); + if (inner.kind === ts.SyntaxKind.ThisKeyword) return sloppyReceiverOf(inner); + if (!ts.isIdentifier(inner)) return false; + const declarations = lowerer.checker.getSymbolAtLocation(inner)?.declarations ?? []; + const declaration = declarations[0]; + if ( + declarations.length !== 1 || + declaration === undefined || + !ts.isVariableDeclaration(declaration) + ) + return false; + const init = + declaration.initializer === undefined ? undefined : withoutParentheses(declaration.initializer); + return init !== undefined && init.kind === ts.SyntaxKind.ThisKeyword && sloppyReceiverOf(init); +} + +function withoutParentheses(expr: ts.Expression): ts.Expression { + let inner = expr; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + return inner; +} + +/** Whether the `this` at `node` is a sloppy-mode function's receiver + * (lower-functions.ts declareCallReceiver): arrows take the enclosing one. */ +function sloppyReceiverOf(node: ts.Node): boolean { + for (let parent = node.parent; parent !== undefined; parent = parent.parent) { + if (ts.isArrowFunction(parent)) continue; + if (ts.isFunctionLike(parent)) return parent.body !== undefined && !isStrictCode(parent.body); + if ( + ts.isSourceFile(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isClassStaticBlockDeclaration(parent) + ) + return false; + } + return false; +} + +/** The names of the global object's declared properties that the lookup can + * answer. */ +function declaredGlobalNames(lowerer: Lowerer, at: ts.Node): string[] { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(at), + false, + ); + if (symbol === undefined) return []; + return lowerer.checker + .getPropertiesOfType(lowerer.checker.getTypeOfSymbol(symbol)) + .map((property) => ts.symbolName(property)) + .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); +} + +/** The names the program's lookup answers: every declared global when a + * module reads the global object by computed names, else the ones its + * sloppy-mode functions read through `this`. */ +function lookupNames(lowerer: Lowerer): readonly string[] { + if (lowerer.globalLookupNames !== undefined) return lowerer.globalLookupNames; + const modules = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]; + const names = modules.some((sf) => computedGlobalReadOf(lowerer, sf) !== null) + ? declaredGlobalNames(lowerer, lowerer.entry) + : modules.flatMap((sf) => [...sloppyThisGlobalReadsOf(lowerer, sf)]); + lowerer.globalLookupNames = [...new Set(names)].sort(); + return lowerer.globalLookupNames; +} + +/** Whether the JavaScript source `sf` installs the program's lookup when its + * body starts: it reads builtin globals through the global object by + * computed names, or through a sloppy-mode function's `this`. */ +export function installsGlobalValues(lowerer: Lowerer, sf: ts.SourceFile): boolean { + return ( + computedGlobalReadOf(lowerer, sf) !== null || sloppyThisGlobalReadsOf(lowerer, sf).size > 0 + ); +} + /** The statement that installs the program's lookup, built on first use - * from the names the checker declares on `receiver` (a global object). */ -export function globalValuesInstall( - lowerer: Lowerer, - receiver: ts.Expression, - loc: SrcLoc, -): IrStmt { + * from the names it answers (lookupNames). */ +export function globalValuesInstall(lowerer: Lowerer, loc: SrcLoc): IrStmt { const fnType: IrType & { kind: "func" } = { kind: "func", params: [STRING], ret: DYN }; if (!lowerer.liftedFnNamed(LOOKUP_FN)) { - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(receiver)) - .map((property) => ts.symbolName(property)) - .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); const name: IrExpr = { kind: "varRef", localId: "name.0", type: STRING, loc }; const body: IrStmt[] = []; - for (const global of [...new Set(names)].sort()) { + for (const global of lookupNames(lowerer)) { const webStream = (WEB_STREAM_CLASSES as readonly string[]).indexOf(global); const value: IrExpr | null = REFUSED.has(global) ? { kind: "unitLit", unit: "null", type: { kind: "nullT" }, loc } @@ -192,7 +327,7 @@ export function globalValuesInstall( ? programNamesWebStreamClass(lowerer) ? webStreamClassValue(webStream, loc) : null - : jsGlobalValueByName(lowerer, global, loc); + : jsGlobalValueByName(lowerer, global, loc, false); if (value === null) continue; body.push({ kind: "if", diff --git a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts index d59b234d3..1058b1c26 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtin-values.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtin-values.ts @@ -426,21 +426,86 @@ const box = (value: IrExpr): IrExpr => value.type.kind === "dyn" ? value : { kind: "dynFrom", value, type: DYN, loc: value.loc }; export function pathModuleValue(lowerer: Lowerer, module: string, loc: SrcLoc): IrExpr { - const fields: { key: IrExpr; value: IrExpr }[] = []; - const add = (member: string, value: IrExpr): void => { - fields.push({ key: strLit(member, loc), value: box(value) }); + // node:path's namespaces are module objects: the same handle + // `process.getBuiltinModule("node:path")` answers. The handle caches + // each member (so `path.join === path.join` and a mutation through one + // spelling is visible through the other) and identifies the module, so + // `posix === path.posix` holds — the object model the platform + // namespaces need when they are VALUES (`const api = win32`). + return moduleValue(lowerer, module, loc); +} + +/** Node's own keys of the node:path module objects (path, path.posix and + * path.win32), in order; `_makeLong` is the same function as + * toNamespacedPath. */ +const PATH_MODULE_KEYS = [ + "resolve", + "normalize", + "isAbsolute", + "join", + "relative", + "toNamespacedPath", + "dirname", + "basename", + "extname", + "format", + "parse", + "matchesGlob", + "sep", + "delimiter", + "win32", + "posix", + "_makeLong", +] as const; + +/** What a spread of a node:path namespace copies: the module object's own + * members in Node's order, each read through the module value, so the copy + * holds the same functions (`{ ...path }.join === path.join`). An ES module + * namespace (`import * as path`) has its keys sorted and also `default`, + * the module object. Members the module value does not answer (format and + * parse have no function values) are left out, as they are of the value. */ +export function pathModuleSpreadSource( + lowerer: Lowerer, + module: string, + namespace: boolean, + loc: SrcLoc, +): IrExpr { + const local = lowerer.declareHiddenLocal("%pathModule", DYN); + const value = varRef(local.id, DYN, loc); + const answered = (key: string): boolean => + key === "default" || + key === "posix" || + key === "win32" || + builtinModuleConstOf(lowerer, module, key) !== undefined || + lowerer.lowerBuiltinCallableValue( + { module, member: key === "_makeLong" ? "toNamespacedPath" : key }, + loc, + ) !== null; + const keys = (namespace ? [...PATH_MODULE_KEYS, "default"].sort() : [...PATH_MODULE_KEYS]).filter( + answered, + ); + const fields = keys.map((key) => ({ + key: strLit(key, loc), + value: + key === "default" + ? value + : ({ + kind: "dynKeyGet", + value, + key: strLit(key === "_makeLong" ? "toNamespacedPath" : key, loc), + type: DYN, + loc, + } as IrExpr), + })); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: local.id, init: pathModuleValue(lowerer, module, loc), loc }, + ], + result: { kind: "dynObjLit", fields, type: DYN, loc }, + type: DYN, + loc, }; - for (const member of Object.keys(builtinModuleFns()[module] ?? {})) { - const value = lowerer.lowerBuiltinCallableValue({ module, member }, loc); - if (value) add(member, value); - } - for (const member of Object.keys(BUILTIN_MODULE_CONSTS[module] ?? {})) { - const value = builtinModuleConstOf(lowerer, module, member); - if (value !== undefined) add(member, builtinConstLit(value, loc)); - } - add("posix", moduleValue(lowerer, "path/posix", loc)); - add("win32", moduleValue(lowerer, "path/win32", loc)); - return { kind: "dynObjLit", fields, type: DYN, loc }; } export function utilTypesModuleValue(lowerer: Lowerer, loc: SrcLoc): IrExpr { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index de5bab8b7..e9d79213c 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3707,7 +3707,17 @@ function lowerDynReceiverMethodCall( const binding = ts.isIdentifier(access.expression) ? lowerer.builtinImportOf(access.expression) : null; - if (binding?.module !== "worker_threads" || binding.member !== "parentPort") return null; + const parentPort = binding?.module === "worker_threads" && binding.member === "parentPort"; + // node:path's platform namespaces resolve to ambient declarations the + // same way, but their VALUE is the checked-dynamic module object (the + // whole namespace), so `win32.join(...)` takes this receiver path + // exactly like `path.win32.join(...)`. + const pathNamespace = + (binding?.module === "path" || + binding?.module === "path/posix" || + binding?.module === "path/win32") && + (binding.member === "posix" || binding.member === "win32"); + if (!parentPort && !pathNamespace) return null; } // Only checker-untyped receivers: `any`/`unknown`, or the `any[]` an // Array.isArray guard narrows them to (the value is STILL the checked-dynamic tree @@ -4919,6 +4929,9 @@ function lowerArrayIsArrayCall( // itself instead of folding on the extracted type (typeof's rule). if (arg.kind === "dynCheck" && arg.value.type.kind === "dyn") arg = arg.value; if (arg.type.kind === "dyn") { + // With Node islands the value may be an island array (scope-B NI-4). + if (lowerer.nodeIslands !== null) + return { kind: "libCall", fn: "nodeIsland.isArray", args: [arg], type: BOOL, loc }; return { kind: "dynTest", test: "array", value: arg, type: BOOL, loc }; } if (arg.type.kind === "union") { @@ -5142,11 +5155,22 @@ function lowerUnionToStringCall( access: ts.PropertyAccessExpression, ): IrExpr | null { if (call.questionDotToken || access.questionDotToken) return null; - if (access.name.text !== "toString" || call.arguments.nodes.length !== 0) return null; + if (access.name.text !== "toString") return null; + // `.toString("utf8")` on `string | Buffer` (a Buffer's default decode; + // String.prototype.toString ignores its arguments): the same helper. + const args = call.arguments.nodes; + const utf8Arg = + args.length === 1 && ts.isStringLiteralLike(args[0]!) && /^utf-?8$/i.test(args[0].text); + if (args.length !== 0 && !utf8Arg) return null; const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (recvT?.kind !== "union") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(recvT.unionId); + if ( + utf8Arg && + !def?.arms.every((a) => a.kind === "string" || (a.kind === "bytes" && a.elem === "u8")) + ) + return null; const stringable = def?.arms.every( (a) => a.kind === "string" || diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..4c8743d32 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"; @@ -3451,7 +3455,15 @@ export function collectClassShapeInner( if (shape.bodyType) shape.bodyType = DYN; } ft.params = shapes.map((shape) => shape.type); - if (ft.ret.kind !== "void") ft.ret = DYN; + // A GENERATOR method's result is the suspended generator its spawn + // wrapper answers, so the generator kind IS the checked-crossable + // ABI: dynFrom boxes a generator by reference and dyn calls resume + // it through the dyn generator ops. Replacing it with plain DYN + // would leave the method no representation at all (the generator + // branch below needs the channels to build its metadata), which + // fenced every `c.m` read on a prototype-accessed class. Only the + // ARGUMENTS cross as checked values here. + if (ft.ret.kind !== "void" && member.asteriskToken === undefined) ft.ret = DYN; } // JS subclasses can add parameters to a method. Reserve checked // argument slots in the base before constructing its vtable; shorter @@ -11062,6 +11074,35 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = ctxMapped; } } + // `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-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 690f8a5ef..306fbcb2c 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2953,6 +2953,7 @@ export function typeofAnswer(arm: IrType): string | null { case "url": case "searchParams": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index e29483f22..48cdfa9cd 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -38,6 +38,8 @@ import { blockBodyOf, } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; +import { isStrictCode } from "./strict-mode.js"; +import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -48,16 +50,28 @@ function declareCallReceiver( ): IrStmt { const receiver = lowerer.declareThis(DYN); const loc = locOf(node); + const bound: IrExpr = { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }; + // A sloppy-mode function's `this` is the global object when it is called + // with undefined or null. The function's own body decides its mode. + const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body); return { kind: "varDecl", localId: receiver.id, - init: { - kind: "libCall", - fn: generator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }, + init: sloppy + ? { + kind: "nullish", + left: bound, + right: lowerSloppyGlobalReceiver(lowerer, node, loc), + type: DYN, + loc, + } + : bound, loc, }; } @@ -298,9 +312,15 @@ export function lowerLambda( // A lambda IS a value: the completed-ABI rule applies at birth. The // contextual (target) type decides — `(x?: number) => void` may flow // into a slot annotated `(x: number | undefined) => void` (same ABI - // signature), anything else is fenced. Nested function declarations and - // object-literal shorthand methods aren't expressions — always fenced. + // signature), anything else is fenced. Object-literal shorthand methods + // aren't expressions: their slot is the literal's, judged inside. + // A nested function DECLARATION is not an expression either: lowering it + // builds the body, not a value, and every value use re-runs the same + // fence at the reference (identifiers.ts) with that use's own target + // (a call, or `const g = f` under an annotated slot). Fencing it here + // refused declarations that are only ever CALLED. if ( + !ts.isFunctionDeclaration(node) && !( ts.isMethodDeclaration(node) && isJsSourceFile(ts.getSourceFileOfNode(node)) && diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index e7ab6b0f0..53e538298 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; +import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -8,11 +8,29 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; * Known globals without a value implementation refuse at runtime; absent host * capabilities answer undefined. The checker supplies names, never values. */ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr { - const loc = locOf(expr); + return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr)); +} + +/** The global object a sloppy-mode function receives as `this` when it is + * called with undefined or null: the value `globalThis` reads. */ +export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(node), + false, + ); + return lowerGlobalValueOfType( + lowerer, + symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol), + loc, + ); +} + +function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr { // The temporal global is typed for the programs that name it (TS 7's // lib slice) but absent in Node 24: reads answer undefined. - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(expr)) + const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type)) .map((property) => ts.symbolName(property)) .filter((name) => name !== TEMPORAL_GLOBAL); return { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9e04cc246..f82728c6e 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,9 +1,9 @@ import { isIslandPromisifyCall, noteIslandSubclass } from "./lower-node-island.js"; import { isIslandSubclassDecl } from "../../node-islands/boundary.js"; -import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js"; +import { globalValuesInstall, installsGlobalValues } from "./global-values.js"; import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; -import { collectIslandImport } from "./lower-node-island.js"; +import { collectIslandImport, collectIslandRequires } from "./lower-node-island.js"; import { NODE_API_MODULE } from "../node-api-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -732,7 +732,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { } } } - for (const fp of parts) collectDynamicImports(lowerer, fp.sf); + for (const fp of parts) { + collectDynamicImports(lowerer, fp.sf); + collectIslandRequires(lowerer, fp.sf); + } } /** Literal dynamic-import collection: every @@ -3169,10 +3172,10 @@ export function lowerFileInit( // (NI-6); the plan also creates their declarations' TDZ flags. if (sharedModulePlans(lowerer).length > 0 && sf === lowerer.entry) header.push(...sharedModuleRegistrations(lowerer)); - // A module that reads builtin globals through the global object by - // computed names installs the program's lookup before its body runs. - const computedGlobalRead = computedGlobalReadOf(lowerer, sf); - if (computedGlobalRead) header.push(globalValuesInstall(lowerer, computedGlobalRead, loc0)); + // A module that reads builtin globals through the global object, by + // computed names or through a sloppy-mode function's `this`, installs + // the program's lookup before its body runs. + if (installsGlobalValues(lowerer, sf)) header.push(globalValuesInstall(lowerer, loc0)); // Top-level `this` of a CommonJS module: its object exists before the // module's first statement (lowerCjsTopLevelThis). const exportsThis = lowerer.cjsExportsThis.get(sf); diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 739918e12..46354dedc 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -413,10 +413,32 @@ export function collectIslandImport( // module evaluated. const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined; const named = clause?.namedBindings; + const snapshotNames: string[] = []; if (!binding.esm && named !== undefined && ts.isNamedImports(named)) { for (const el of named.elements.nodes) { if (el.isTypeOnly) continue; - const name = el.propertyName?.text ?? el.name.text; + snapshotNames.push(el.propertyName?.text ?? el.name.text); + } + } + // A CommonJS namespace import's members are the same snapshot: the + // members the file reads (`ns.name`). + if (!binding.esm && named !== undefined && ts.isNamespaceImport(named)) { + const nsSymbol = lowerer.checker.getSymbolAtLocation(named.name); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isIdentifier(node.name) && + node.expression.text === named.name.text && + lowerer.checker.getSymbolAtLocation(node.expression) === nsSymbol && + !snapshotNames.includes(node.name.text) + ) + snapshotNames.push(node.name.text); + return undefined; + }); + } + { + for (const name of snapshotNames) { if (name === "default") continue; let g = binding.snapshots.get(name); if (g === undefined) { @@ -451,6 +473,7 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { } type IslandBinding = + | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; @@ -487,7 +510,13 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const name = decl.propertyName?.text ?? decl.name.text; binding = name === "default" ? "default" : { name }; } else { - return null; + // `const x = require("pkg")` (a CommonJS module of a native package + // requiring an island package): x is module.exports. + const spec = requireSpecOf(lowerer, decl); + if (spec === null) return null; + const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require"); + const module = path === null ? undefined : lowerer.islandModules.get(path); + return module === undefined ? null : { kind: "required", module }; } const spec = moduleSpecifierOf(importDecl); if (spec === null || importDecl === undefined) return null; @@ -514,6 +543,8 @@ function islandBindingValue( ): IrExpr { const ns = varRef(b.module.global.id, DYN, loc); switch (b.kind) { + case "required": + return ns; case "namespace": if (!b.module.esm) boundaryFail(lowerer, "the namespace object of a CommonJS island module as a value", node); @@ -624,6 +655,8 @@ function copiedType(lowerer: Lowerer, t: IrType): boolean { case "bigint": case "nullT": case "undefinedT": + // Buffers and typed arrays share their memory with the V8 view. + case "bytes": return true; case "union": { const def = lowerer.unions.get(t.unionId); @@ -844,11 +877,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { // NI-5: the runtime exports dyn objects by reference and copies only // primitives, strings and (for now) arrays. return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); - case "refused": + case "refused": { + const type = lowerer.typeOf(node); + // A type parameter the instance binds to dyn (a generic function + // over island types, ``): its values are dyn + // values, and they cross the way `any` values do. + if ( + (type.flags & ts.TypeFlags.TypeParameter) !== 0 && + lowerer.typeParamResolver(type)?.kind === "dyn" + ) + return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); // NI-5: closures, promises, records and class instances cross by // reference (scr_node_island_export.c): their dyn boxes keep the // native referent, and the closure's thunk checks its arguments. - if (exportedByReference(lowerer, lowerer.typeOf(node))) { + if (exportedByReference(lowerer, type)) { const lowered = lowerer.lowerExpr(node); const value = lowerer.coerceToExpected(lowered, DYN); return lowered.type.kind === "object" @@ -857,9 +899,10 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { } return boundaryFail( lowerer, - `a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`, + `a '${lowerer.checker.typeToString(type)}' value (a native object would cross as a copy and lose its identity)`, node, ); + } } } @@ -1041,6 +1084,11 @@ function freshLiteral( node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression, ): IrExpr { const loc = locOf(node); + // Spreads, methods, accessors and computed keys: the literal lowers as + // the program's own object, which crosses by reference (NI-5 exports); + // nothing else holds it, so that is exact. + if (!plainLiteral(node)) + return lowerer.coerceToExpected(lowerer.lowerExprExpecting(node, DYN), DYN); if (ts.isArrayLiteralExpression(node)) { const elems = node.elements.nodes.map((el) => { if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) @@ -1324,7 +1372,7 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) { if (!isForeignExpr(lowerer, callee.expression)) return null; lowerer.usesNodeIsland = true; - const target = islandReceiver(lowerer, callee.expression, callee.name.text, loc); + const target = islandReceiver(lowerer, callee.expression, callee.name.text, loc, true); if (target.direct !== undefined) { return fromIsland( lowerer, @@ -1603,12 +1651,14 @@ function islandCompoundAssign( /** The receiver of a member operation on an island value. A CommonJS * namespace binding's members are module.exports' (and `default` is - * module.exports itself), answered directly. */ + * module.exports itself), answered directly; a call of a member keeps + * module.exports as its receiver, so it goes through the method call. */ function islandReceiver( lowerer: Lowerer, receiver: ts.Expression, member: string, loc: SrcLoc, + call = false, ): { value: IrExpr; direct?: IrExpr } { const inner = stripTypeWrappers(receiver); if (ts.isIdentifier(inner)) { @@ -1616,6 +1666,9 @@ function islandReceiver( if (b !== null && b.kind === "namespace" && !b.module.esm) { const exports = varRef(b.module.global.id, DYN, loc); if (member === "default") return { value: exports, direct: exports }; + const snapshot = b.module.snapshots.get(member); + if (snapshot !== undefined && !call) + return { value: exports, direct: varRef(snapshot.id, DYN, loc) }; return { value: exports }; } } @@ -1943,6 +1996,82 @@ export function lowerIslandSuperMethodCall( return fromIsland(lowerer, raw, call); } +/** A literal freshLiteral copies member by member: identifier or string + * keys with values, shorthand properties, array elements without spreads + * or holes. */ +function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): boolean { + if (ts.isArrayLiteralExpression(node)) + return node.elements.nodes.every( + (el) => !ts.isSpreadElement(el) && !ts.isOmittedExpression(el), + ); + return node.properties.nodes.every( + (p) => + (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) || + ts.isShorthandPropertyAssignment(p), + ); +} + +/** The specifier of `x = require("spec")` (a variable declaration binding + * an identifier to a literal require), or null. */ +function requireSpecOf(lowerer: Lowerer, decl: ts.Node): string | null { + if ( + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.initializer === undefined + ) + return null; + // Only a const keeps the module: a let or var can be reassigned. + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if ( + !ts.isCallExpression(init) || + !ts.isIdentifier(init.expression) || + init.expression.text !== "require" || + init.arguments.nodes.length !== 1 + ) + return null; + // The global require, not a function of the program named require. + if ( + lowerer.checker + .getSymbolAtLocation(init.expression) + ?.declarations.some((d) => !ts.getSourceFileOfNode(d).isDeclarationFile) + ) + return null; + const arg = init.arguments.nodes[0]!; + return ts.isStringLiteralLike(arg) ? arg.text : null; +} + +/** Top-level `const x = require("pkg")` statements of island packages in a + * compiled module (a native package requiring an island package): load the + * island module in the module's %init prelude (shortcut: before the body, + * not at the statement). */ +export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { + const islands = lowerer.nodeIslands; + if (islands === null) return; + for (const stmt of sf.statements.nodes) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations.nodes) { + const spec = requireSpecOf(lowerer, decl); + if (spec === null) continue; + const path = islandModuleFile(islands, sf.fileName, spec, "require"); + if (path === null) continue; + lowerer.usesNodeIsland = true; + const loc = locOf(decl); + const binding = islandModuleGlobal(lowerer, path); + const actions = lowerer.jsonInitActions.get(sf) ?? []; + lowerer.jsonInitActions.set(sf, actions); + actions.push({ + kind: "assign", + localId: binding.global.id, + value: loadIslandModule(lowerer, path, loc), + loc, + }); + } + } +} + /* `x instanceof Error` for values that may be island objects (NI-5c). */ const INTRINSIC_ERRORS = new Set([ "Error", diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index 78d970d21..ea0cfb702 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -341,6 +341,11 @@ export function statefulReason(sf: ts.SourceFile): string | null { ) continue; if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + // CommonJS export statements (`module.exports = f`, `exports.x = v`, + // `module.exports.x = v`) and expandos on the module's own functions + // (`f.sync = g`) declare the module's surface; they are not effects. + if (ts.isExpressionStatement(stmt) && isCjsDeclaringAssignment(stmt.expression, functions(sf))) + continue; return "its evaluation has effects beyond declarations"; } // Writes from function bodies to module-level bindings. @@ -600,3 +605,39 @@ export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { }); return out; } + +/** The names of a module's top-level function declarations. */ +function functions(sf: ts.SourceFile): Set { + const names = new Set(); + for (const stmt of sf.statements.nodes) + if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +/** `module.exports = …`, `exports.x = …`, `module.exports.x = …` (also + * chained: `exports = module.exports = …`), or `f.x = …` for a top-level + * function `f`. */ +function isCjsDeclaringAssignment(e: ts.Expression, fns: Set): boolean { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return false; + const left = e.left; + const isModuleExports = (n: ts.Expression): boolean => + ts.isPropertyAccessExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "module" && + ts.isIdentifier(n.name) && + n.name.text === "exports"; + const target = + (ts.isIdentifier(left) && left.text === "exports") || + isModuleExports(left) || + (ts.isPropertyAccessExpression(left) && + ((ts.isIdentifier(left.expression) && + (left.expression.text === "exports" || fns.has(left.expression.text))) || + isModuleExports(left.expression))); + if (!target) return false; + // A chained assignment declares too. + const right = e.right; + if (ts.isBinaryExpression(right) && right.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return isCjsDeclaringAssignment(right, fns); + return true; +} diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index c452beb94..bad905e03 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"; @@ -259,7 +260,7 @@ import { import { keptIdentityAssertion } from "./assertion-identity.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { lowerUnionFieldWrite } from "./expressions/union-fields.js"; +import { lowerUnionFieldDelete, lowerUnionFieldWrite } from "./expressions/union-fields.js"; import { UNSUPPORTED, checkerPanicDiag, @@ -2617,7 +2618,7 @@ export function lowerDestructuringDecl( init.type.kind === "dyn" && ts.isObjectBindingPattern(decl.name) && isJsSourceFile(ts.getSourceFileOfNode(decl)) && - startsWithRuntimeKey(lowerer, decl.name) && + (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) && destrSpellingOf(decl.initializer) !== null; if ( jsRuntimeKeyCheck || @@ -3181,6 +3182,39 @@ export function lowerBindingPattern( // every statically non-nullish kind, record or not (the assignment // path's rule); possibly-nullish sources would throw in JS and keep // a fence. + // + // A CHECKED-DYNAMIC source still owes that step at runtime: an empty + // object pattern over null/undefined throws RequireObjectCoercible, + // and an empty array pattern throws GetIterator's "is not iterable". + // Nothing else about the pattern is observable, so the fence is the + // whole lowering. + if (srcType.kind === "dyn") { + const unitLoc = locOf(pattern); + const isObject = ts.isObjectBindingPattern(pattern); + for (const unit of ["null", "undefined"] as const) { + // V8 describes an argument-derived source by its type ("object + // null"); a bare non-empty pattern names its source instead, but + // those reads throw on their own before this check could fire. + const described = unit === "null" ? "object null" : "undefined"; + const message = isObject + ? `Cannot destructure '${described}' as it is ${unit}.` + : `${described} is not iterable (cannot read property Symbol(Symbol.iterator))`; + out.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: srcRef(), type: BOOL, loc: unitLoc }, + then: [ + { + kind: "exprStmt", + expr: nodeThrowExpr(1, "", message, VOID, unitLoc), + loc: unitLoc, + }, + ], + else_: null, + loc: unitLoc, + }); + } + return; + } if ( isUnitType(srcType) || (srcType.kind === "union" && @@ -4738,6 +4772,16 @@ function stdlibGlobalTokenDestructure( ) { // A data member reads its value once, exactly like the spelled // `process.` read the destructuring performs. + } else if ( + globalName === "process" && + propName === "cwd" && + (value = lowerer.lowerBuiltinCallableValue( + { module: "process", member: propName }, + locOf(el), + )) !== null + ) { + // process.cwd reads the directory when it is called, so the detached + // function is the same one every other spelling reads. } else { lowerer.unsupported( "SC1031", @@ -6426,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. @@ -7732,6 +7789,14 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt loc, }; } + // `delete u.f` on a union of records that every arm declares `f` optional + // on: dispatch over the arms and write each slot's ABSENT state (the + // monomorphic optional-field delete). Required fields, class arms and + // other receivers keep their own fences below. + if (ts.isPropertyAccessExpression(target)) { + const unionDelete = lowerUnionFieldDelete(lowerer, target); + if (unionDelete) return unionDelete; + } let obj = lowerer.lowerExpr(target.expression); // `delete this.stack` in an Error subclass that declares a `stack` // accessor (quickjs-wasi's JSException): V8 gives each error an own diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 37b35ceaa..e90e07827 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -288,6 +288,7 @@ import { noLoweringDiag, overloadedSignatureTypeDiag, recordShapeMismatchDiag, + stdlibSnapshotValueUseDiag, unsupportedNativeApiDiag, unsupportedPackageValueDiag, unionMismatchDiag, @@ -2111,6 +2112,67 @@ function anyPiecedFuncType(lowerer: Lowerer, node: ts.Node, t: ts.Type): IrType * their own name, anonymous function/arrow expressions their * NamedEvaluation home (a variable initializer or property assignment). * Null when nothing names the value (the box stays anonymous). */ +/** The name Node gives the builtin module function `node` reads, through + * const aliases (`const get = process.cwd`), when the module table names its + * value differently from its member (BuiltinModuleFn.valueName); else null. */ +export function builtinValueNameOf(node: ts.Node, lowerer: Lowerer): string | null { + let n: ts.Node = node; + const seen = new Set(); + for (;;) { + while (ts.isParenthesizedExpression(n)) n = n.expression; + if (!ts.isIdentifier(n)) break; + const imported = lowerer.builtinImportOf(n); + if (imported) + return builtinModuleFnOf(lowerer, imported.module, imported.member)?.valueName ?? null; + const symbol = lowerer.resolveValueSymbol(n); + const declaration = symbol?.valueDeclaration; + if (!symbol || seen.has(symbol) || !declaration) return null; + seen.add(symbol); + // A member destructured from a module or the process global + // (`const { cwd } = process`). + if (ts.isBindingElement(declaration)) { + const pattern = declaration.parent; + const holder = pattern?.parent; + const key = declaration.propertyName ?? declaration.name; + if ( + declaration.dotDotDotToken || + !pattern || + !ts.isObjectBindingPattern(pattern) || + !holder || + !ts.isVariableDeclaration(holder) || + !holder.initializer || + (ts.getCombinedNodeFlags(holder) & ts.NodeFlags.Const) === 0 || + !ts.isIdentifier(key) + ) + return null; + return builtinMemberValueName(lowerer, holder.initializer, key.text); + } + if ( + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 + ) + return null; + n = declaration.initializer; + } + if (!ts.isPropertyAccessExpression(n)) return null; + return builtinMemberValueName(lowerer, n.expression, n.name.text); +} + +/** BuiltinModuleFn.valueName of `member` read from `receiver`, the process + * global or a builtin module's namespace; null for anything else. */ +function builtinMemberValueName( + lowerer: Lowerer, + receiver: ts.Expression, + member: string, +): string | null { + const module = lowerer.isStdlibGlobal(receiver, "process") + ? "process" + : lowerer.builtinNamespaceModuleOf(receiver); + if (module === null) return null; + return builtinModuleFnOf(lowerer, module, member)?.valueName ?? null; +} + export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { let n: ts.Node = node; while (ts.isParenthesizedExpression(n)) n = n.expression; @@ -2138,14 +2200,18 @@ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { if (ts.isPropertyAccessExpression(n)) return n.name.text; if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) return n.name.text; if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) { - const p = n.parent; - if (p && ts.isVariableDeclaration(p) && p.initializer === n && ts.isIdentifier(p.name)) { + // An anonymous function takes the name of the binding or property it + // initializes, through parentheses (`const f = (function () {})`). + let outer: ts.Node = n; + while (outer.parent && ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const p = outer.parent; + if (p && ts.isVariableDeclaration(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } - if (p && ts.isBindingElement(p) && p.initializer === n && ts.isIdentifier(p.name)) { + if (p && ts.isBindingElement(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } - if (p && ts.isPropertyAssignment(p) && p.initializer === n && ts.isIdentifier(p.name)) { + if (p && ts.isPropertyAssignment(p) && p.initializer === outer && ts.isIdentifier(p.name)) { return p.name.text; } } @@ -3391,6 +3457,16 @@ export class Lowerer { * checks resolve through this map (stdlibGlobalNameOf), declarations * emit nothing. */ readonly stdlibGlobalAliases = new Map(); + /** The TypeScript declarations registered in stdlibGlobalAliases: the + * global each one names and its own name node. Their declarations emit + * nothing, so a read that needs the VALUE reports at the declaration + * (stdlibGlobalAliasDecl, rejectUnresolvedSymbol) instead of compiling + * it away. An unread snapshot stays elided: reading a global has no + * side effects. */ + readonly stdlibGlobalAliasDecls = new Map(); + /** Snapshots whose declaration-side refusal already reported (one + * declaration, one report). */ + private readonly stdlibSnapshotReported = new Set(); /** The stdlibGlobalAliases bound through a cast of the global object * (`(globalThis as { Buffer?: B }).Buffer`). Their static type is the * cast's, so their value reads are refused (identifiers.ts). */ @@ -3787,6 +3863,12 @@ export class Lowerer { /** global-values.ts's answer per JavaScript source: the receiver of its * first read of the global object by a computed name, or null. */ readonly computedGlobalReads = new Map(); + /** global-values.ts's answer per JavaScript source: the builtin globals + * its sloppy-mode functions read through their `this`. */ + readonly sloppyThisGlobalReads = new Map>(); + /** global-values.ts's answer: the names the program's global lookup + * answers; undefined until asked. */ + globalLookupNames: readonly string[] | undefined = undefined; /** web-streams-dyn.ts's answer: whether a JavaScript source names a web * stream class; undefined until asked. */ namesWebStreamClass: boolean | undefined = undefined; @@ -4239,6 +4321,10 @@ export class Lowerer { checkedTable("methodLambdasInProgress", () => this.methodLambdasInProgress.size), checkedTable("classMethodValueSelections", () => this.classMethodValueSelections.size), checkedTable("stdlibGlobalAliases", () => this.stdlibGlobalAliases.size), + // The snapshots' own declarations and their one-report memory: both + // grow with stdlibGlobalAliases, one entry per registered declaration. + checkedTable("stdlibGlobalAliasDecls", () => this.stdlibGlobalAliasDecls.size), + checkedTable("stdlibSnapshotReported", () => this.stdlibSnapshotReported.size), checkedTable("cjsAccessorFns", () => this.cjsAccessorFns.size), checkedTable("cjsAccessorSnapshots", () => this.cjsAccessorSnapshots.size), checkedTable("cjsSnapshotReady", () => this.cjsSnapshotReady.size), @@ -4419,6 +4505,8 @@ export class Lowerer { "createRequireBindingTargets", "createRequireElementTargets", "computedGlobalReads", + "sloppyThisGlobalReads", + "globalLookupNames", "namesWebStreamClass", "timeValueDateDecisions", "timeValueDateWrites", @@ -11397,6 +11485,24 @@ export class Lowerer { throw new PoisonError(); } + /** The declaration-side refusal of a stdlib-global snapshot read as a + * VALUE (stdlibGlobalAliasDecl): report once, at the declaration's name, + * naming the type the program asked the compiler to hold when it has a + * short spelling. */ + reportStdlibSnapshot(symbol: ts.Symbol): void { + const decl = this.stdlibGlobalAliasDecls.get(symbol); + if (decl === undefined || this.stdlibSnapshotReported.has(symbol)) return; + this.stdlibSnapshotReported.add(symbol); + const raw = panicSafe( + () => this.checker.typeToString(this.checker.getTypeOfSymbol(symbol)), + "", + ); + const typeText = raw !== "" && raw.length <= 48 ? raw : undefined; + this.pushDiag( + stdlibSnapshotValueUseDiag(decl.name.text, decl.alias, typeText, locOf(decl.name)), + ); + } + stdlibMemberFence(access: ts.PropertyAccessExpression): void { return stdlibMemberFence(this, access); } @@ -12334,20 +12440,13 @@ export class Lowerer { liveReference(expr.type) || (expr.type.kind === "union" && (this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false)); - // A builtin function's adapter boxes under the builtin's name, the - // name Node's function carries (`[Function: randomUUID]`). - const builtinName = - expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined; - const builtinLength = - expr.kind === "closure" ? this.builtinCallableValueLengths.get(expr.fnName) : undefined; return { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), - ...(builtinName !== undefined ? { fnName: builtinName } : {}), - ...(builtinLength !== undefined ? { fnLength: builtinLength } : {}), + ...this.builtinFunctionIdentity(expr), }; } return expr; @@ -12495,7 +12594,13 @@ export class Lowerer { ) { return { kind: "dynCheck", - value: { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc }, + value: { + kind: "dynFrom", + value: expr, + type: DYN, + loc: expr.loc, + ...this.builtinFunctionIdentity(expr), + }, type: expected, loc: expr.loc, }; @@ -15547,6 +15652,18 @@ export class Lowerer { this.pushDiag(blockedBindingUseDiag(name, locOf(node))); throw new PoisonError(); } + // A stdlib-global snapshot read as a VALUE (`return proc` for + // `const proc = globalThis.process`): the declaration emitted no + // storage — alias plumbing only serves member and call reads — and the + // global's own value has no lowering. The cause is the declaration, so + // report there (once) and cascade here, instead of the generic + // binding-form text that names neither. + if (symbol !== null && this.stdlibGlobalAliasDecls.has(symbol)) { + this.reportStdlibSnapshot(symbol); + this.blockedBindings.add(symbol); + this.pushDiag(blockedBindingUseDiag(name, locOf(node))); + throw new PoisonError(); + } // A binding whose TYPE keeps a generic call signature and whose // declaration carries no initializer (`declare const o4: undefined | // ((f: (a: T) => T) => T)` — the optional-chained ambient shape): @@ -16930,7 +17047,10 @@ export class Lowerer { if (!fnName) { fnName = keyedHelperName(this, `%builtin.value.${fn.valueFn ?? fn.fn}`, key); this.builtinCallableValueFns.set(key, fnName); - this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); + this.builtinCallableValueNames.set( + fnName, + fn.valueName ?? display.slice(display.lastIndexOf(".") + 1), + ); if (fn.valueLength !== undefined) this.builtinCallableValueLengths.set(fnName, fn.valueLength); const params = funcType.params.map((type, index) => ({ @@ -17022,6 +17142,19 @@ export class Lowerer { return { kind: "closure", fnName, captures: [], type: funcType, loc }; } + /** The name and length a builtin function's adapter boxes under: the ones + * Node's function carries (`[Function: randomUUID]`). Empty for any other + * value. */ + builtinFunctionIdentity(expr: IrExpr): { fnName?: string; fnLength?: number } { + if (expr.kind !== "closure") return {}; + const name = this.builtinCallableValueNames.get(expr.fnName); + const length = this.builtinCallableValueLengths.get(expr.fnName); + return { + ...(name !== undefined ? { fnName: name } : {}), + ...(length !== undefined ? { fnLength: length } : {}), + }; + } + isBuiltinCallableValue(expr: IrExpr): boolean { return ( expr.kind === "closure" && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..fc2a5f389 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -823,6 +823,9 @@ export interface BuiltinModuleFn { /** The function value's `length` when it is not the count of its * required value parameters (`Math.max`'s 2 over its rest pack). */ valueLength?: number; + /** The function value's `name` when it is not the member's + * (`process.cwd` is Node's `wrappedCwd`). */ + valueName?: string; } export type BuiltinValueParam = @@ -1894,6 +1897,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { { mode: "optional", type: DYN, defaultValue: undefined }, ], }, + // util.debug is util.debuglog, under its name. debug: { fn: "util.debuglog", params: [DYN, DYN], @@ -1902,6 +1906,7 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { { mode: "required", type: DYN }, { mode: "optional", type: DYN, defaultValue: undefined }, ], + valueName: "debuglog", }, }, // The util/types spoke supplies the probe name to the shared brand check. @@ -1954,10 +1959,29 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { transcode: { fn: "buffer.transcode", params: [DYN, DYN, DYN], result: BYTES_U8 }, }, tty: { - isatty: { fn: "process.isTTY", params: [F64], result: BOOL }, + isatty: { + fn: "process.isTTY", + params: [F64], + result: BOOL, + // As a value (loggers store `tty.isatty`), the adapter takes any + // argument: a value that is not an integral fd answers false, and is + // never coerced. + valueFn: "process.isTTYValue", + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + valueLength: 1, + }, }, process: { loadEnvFile: { fn: "process.loadEnvFile", params: [DYN], result: VOID }, + // Every spelling of process.cwd as a value is this one function, which + // reads the directory when it is called. + cwd: { + fn: "process.cwd", + params: [], + result: STRING, + valueParams: [], + valueName: "wrappedCwd", + }, }, // node:string_decoder's surface is the StringDecoder CLASS — new/write/ // end are special-cased (lowerNew + lowerStringDecoderMethodCall); no @@ -2289,6 +2313,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< randomInt: ["crypto.randomIntMax", "crypto.randomIntCb", "crypto.randomIntPromise"], randomUUID: ["crypto.randomUUIDOpts"], }, + tty: { + isatty: ["process.isTTYValue"], + }, os: { // lowerOsUserInfoCall assembles the record from scalar libCalls. userInfo: ["os.userHomedir", "os.userShell"], @@ -2720,12 +2747,6 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { fns: ["process.release"], note: "the compatibility target's release metadata as one stable read-only object", }, - { - id: "node-builtin.process.cwd", - kind: "node-builtin", - name: "process.cwd", - fns: ["process.cwd"], - }, { id: "node-builtin.process.chdir", kind: "node-builtin", @@ -2860,7 +2881,7 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] { id: "node-builtin.process.isTTY", kind: "node-builtin", name: "process.isTTY", - fns: ["process.isTTY"], + fns: ["process.isTTY", "process.isTTYValue"], note: "the isTTY read on process.stdin/stdout/stderr — one surface across the three streams", }, { @@ -3717,6 +3738,15 @@ export function stdlibGlobalAliasDecl( lowerer.stdlibGlobalAliases.set(symbol, name); if (init !== undefined && castGlobalAliasSource(lowerer, init)) lowerer.castGlobalAliases.add(symbol); + // The snapshot exists for reads, and its declaration emits nothing: a + // read that needs its VALUE (an argument, a return) cannot resolve, so + // register the declaration to report the cause there rather than at the + // use (rejectUnresolvedSymbol). TypeScript sources only: JavaScript + // bindings keep their checked-dynamic value paths. A snapshot no read + // resolves stays elided — reading a global has no side effects, so + // eliding an unread one is observationally identical to Node. + if (!isJsSourceFile(ts.getSourceFileOfNode(nameNode))) + lowerer.stdlibGlobalAliasDecls.set(symbol, { alias: name, name: nameNode }); return true; } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 348ca658e..7836b8918 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6410,7 +6410,11 @@ function preflight7(load: LoadResult): { // optional-dependency try/require pattern), so no fence. // Binding forms keep the fence: their downstream reads // would need the module that never loads. - if (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) { + if ( + (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) && + // A Node island package: loaded in Node at the require (NI-4). + !isFrontendIsland(packageNameOfSpecifier(req.spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(req.spec))); } } @@ -6467,7 +6471,10 @@ function preflight7(load: LoadResult): { if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) { // Binding-less by construction — same require-site throw // channel as the statement-level form above. - if (probeNodeRequireRefusal(sf.fileName, spec) === null) { + if ( + probeNodeRequireRefusal(sf.fileName, spec) === null && + !isFrontendIsland(packageNameOfSpecifier(spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); } } diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 47d8d891d..d3b6d20b0 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1087,6 +1087,8 @@ export function formatIrType( return append("bigint"); case "stats": return append("Stats"); + case "bigintStats": + return append("BigIntStats"); case "fileHandle": return append("FileHandle"); case "dir": @@ -1649,6 +1651,27 @@ export function isLegacyUrlSymbol( * (errno, dlopen, priority) and SignalConstants, and the fallback's twins: * the runtime's checked-dynamic constants object, whose members vary by * platform. */ +/** True for node:path's platform-specific namespaces ("path/posix", + * "path/win32"). Their values are the path module object, so they map to + * the checked-dynamic module value (see pathModuleValue in + * lowering/lower-builtin-values.ts), not to a module declaration. */ +export function isPathPlatformNamespaceId(moduleId: string): boolean { + return moduleId === "builtin:path/posix" || moduleId === "builtin:path/win32"; +} + +/** @types/node's `PlatformPath` interface (module "path"): the declared type + * of `path.posix`/`path.win32`. Its values are the path module object. */ +function isPlatformPathType(type: ts.Type, ctx: Pick): boolean { + const sym = type.getSymbol(); + if (sym === undefined || ts.symbolName(sym) !== "PlatformPath") return false; + return sym.declarations.some( + (decl) => + ts.isInterfaceDeclaration(decl) && + ctx.isStdlibFile(ts.getSourceFileOfNode(decl)) && + isDeclaredInAmbientModule(decl, "path"), + ); +} + export function isOsConstantsType( type: ts.Type, ctx: Pick, @@ -2145,7 +2168,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } { const moduleId = ctx.moduleNamespaceId?.(type) ?? null; - if (moduleId !== null) return { kind: "moduleNs", moduleId }; + // node:path's platform namespaces (`path.posix`, `path.win32`) are + // ordinary objects at run time: the same module object the whole + // namespace lowers to (pathModuleValue). Typing them as a module + // namespace declaration made every value read a static type with no + // value lowering; the checked-dynamic module value is the honest + // type, and a union with a plain record arm collapses onto it. + if (moduleId !== null && !isPathPlatformNamespaceId(moduleId)) + return { kind: "moduleNs", moduleId }; + if (moduleId !== null) return DYN; } // A TypeScript object literal that spreads a checked-dynamic value // (`{ ...requestOptions, path, timeout }`): its keys are the source's @@ -2193,6 +2224,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "object", className: "%Error" }; } + // @types/node declares the platform-path namespace value as the + // `PlatformPath` interface of module "path"; its values ARE the path + // module object (the checked-dynamic module value). + if (isPlatformPathType(type, ctx)) return DYN; if (isOsConstantsType(type, ctx) || isLegacyUrlType(type, ctx)) return DYN; // The WebAssembly API's values have the checked-dynamic representation, so // declarations that merely mention them compile; each reached use of the @@ -3683,6 +3718,21 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "stats" }; } + // fs.BigIntStats: the SAME snapshot handle under Node's bigint member + // table (`statSync(p, { bigint: true })`). Declared by @types/node as + // `class BigIntStats` in "fs"; the fallback declarations keep the class + // shape too. Provenance-checked like Stats. + if ( + psym !== undefined && + ts.symbolName(psym) === "BigIntStats" && + psym.declarations.some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(ts.getSourceFileOfNode(d)), + ) + ) { + return { kind: "bigintStats" }; + } // Capacity snapshots and options keep their live object representation // across numeric/BigInt overloads, runtime options, and callbacks. if ( diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 759871465..e9ae14066 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 = @@ -732,6 +734,7 @@ async function compileExecutableNative( emitter: features.emitter, symbol: features.symbol, bigint: features.bigint, + ...(features.bigstats ? { bigstats: true } : {}), searchParams: features.searchParams, qs: features.qs, utilPromisify: features.utilPromisify, @@ -1824,6 +1827,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 } : {}), @@ -2004,6 +2008,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); @@ -2152,6 +2157,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/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 60fa80788..387d82ed4 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2,6 +2,7 @@ import type { IrType } from "./ir.js"; import { BIGINT_T } from "./ir.js"; import { arrayOf, + BIGINT_STATS_T, BOOL, BYTES_U8, bytesOf, @@ -204,6 +205,9 @@ function buildLibFnSignatures() { "nodeIsland.applyTo": { argTypes: [DYN, DYN, DYN], result: DYN }, "nodeIsland.construct": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.instanceof": { argTypes: [DYN, DYN], result: BOOL }, + /** Array.isArray of a dyn value in a program with Node islands: island + * arrays answer true too. */ + "nodeIsland.isArray": { argTypes: [DYN], result: BOOL }, /** NI-5: a class instance crossing into an island, with the builder of * its class's method table (run once per class). Answers the value. */ "nodeIsland.classMethods": { argTypes: [DYN, DYN], result: DYN }, @@ -1379,6 +1383,39 @@ function buildLibFnSignatures() { "stats.mtime": { argTypes: [STATS_T], result: DATE_T }, "stats.ctime": { argTypes: [STATS_T], result: DATE_T }, "stats.birthtime": { argTypes: [STATS_T], result: DATE_T }, + /** BigIntStats (statSync with `{ bigint: true }`): the same snapshot + * handle under Node's bigint member table — every number member of Stats + * (the integers, `*Ms` and the BigIntStats-only `*Ns`) answers `bigint`; + * only the four time members stay `Date`s. */ + "bigstats.isFile": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isDirectory": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isSymbolicLink": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isBlockDevice": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isCharacterDevice": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isFIFO": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.isSocket": { argTypes: [BIGINT_STATS_T], result: BOOL }, + "bigstats.size": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.dev": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ino": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.blocks": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.nlink": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mode": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.uid": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.gid": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.rdev": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.blksize": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mtimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ctimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.birthtimeNs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.mtimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.ctimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.birthtimeMs": { argTypes: [BIGINT_STATS_T], result: BIGINT_T }, + "bigstats.atime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.mtime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.ctime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, + "bigstats.birthtime": { argTypes: [BIGINT_STATS_T], result: DATE_T }, // The wider sync fs slice (unlink/chmod/chown/copyfile and the // mode-carrying write/mkdir forms). /** The wider sync fs slice (scr_lib.c), all throwing catchably with @@ -3600,6 +3637,8 @@ function buildLibFnSignatures() { "process.pid": { argTypes: [], result: F64 }, "dyn.this": { argTypes: [], result: DYN }, "dyn.generatorThis": { argTypes: [], result: DYN }, + /** A sloppy-mode function's `this` from its receiver: the global object + * for undefined and null, else the receiver. Never throws. */ "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended @@ -5349,6 +5388,7 @@ function buildLibFnSignatures() { /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, + "process.isTTYValue": { argTypes: [DYN], result: BOOL }, /** A promise that never settles (events.once of an event that cannot * occur, e.g. 'drain' on a synchronous process stream). The result is * events.once's own Promise type. */ diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts index 86c6a15e5..ae45b987b 100644 --- a/packages/compiler/src/ir/fields.generated.ts +++ b/packages/compiler/src/ir/fields.generated.ts @@ -1863,6 +1863,10 @@ export function walkTypeFields(node: IrType, walker: IrFieldWalker, skipType: bo walker.string(node.kind); return; } + case "bigintStats": { + walker.string(node.kind); + return; + } case "bool": { walker.string(node.kind); return; @@ -2268,6 +2272,7 @@ export type IrFieldsCurrent = [ Unread, "kind" | "elem" | "optionalElements">>, Unread["optionalElements"]>>, Unread, "kind">>, + Unread, "kind">>, Unread, "kind">>, Unread, "kind" | "elem">>, Unread, "kind">>, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 6946ad1cf..63814a60c 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -171,6 +171,12 @@ export type IrType = * never part of a cycle, no trace. Same container rules as url: union * arms fine, arrays/maps/JSON fenced. */ | { kind: "stats" } + /** statSync/lstatSync/fstatSync with `{ bigint: true }`: the SAME snapshot + * handle as `stats` (scr_stats_* retain/release), with Node's BigIntStats + * member table — the integer and `*Ns` members read as `bigint`, the `*Ms` + * members as `number`, the four time members as `Date`. Distinct kind so + * the member reads and the type identity differ from Stats. */ + | { kind: "bigintStats" } /** A node:fs/promises FileHandle: heap, refcounted, MUTABLE — aliases * share one descriptor slot, so close() is idempotent and every alias * observes fd === -1 after closure. The final release closes a still- @@ -401,6 +407,7 @@ export type IrType = const POINTER_HANDLE_KINDS = [ "stats", + "bigintStats", "fileHandle", "dir", "spawnRes", @@ -485,6 +492,7 @@ export const RUNTIME_RC_STEMS: Record = { searchParams: "scr_sp", symbol: "scr_sym", stats: "scr_stats", + bigintStats: "scr_stats", fileHandle: "scr_file_handle", dir: "scr_dir", spawnRes: "scr_spawn_res", @@ -552,6 +560,7 @@ export const REF_TRUTHY_KINDS: ReadonlySet = new Set([ "url", "searchParams", "stats", + "bigintStats", "fileHandle", "dir", "spawnRes", @@ -596,6 +605,7 @@ export const URL_T: IrType = { kind: "url" }; export const SEARCH_PARAMS_T: IrType = { kind: "searchParams" }; export const SYMBOL_T: IrType = { kind: "symbol" }; export const STATS_T: IrType = { kind: "stats" }; +export const BIGINT_STATS_T: IrType = { kind: "bigintStats" }; export const FILEHANDLE_T: IrType = { kind: "fileHandle" }; export const DIR_T: IrType = { kind: "dir" }; export const SPAWNRES_T: IrType = { kind: "spawnRes" }; @@ -669,6 +679,7 @@ export function isSupportedArrayElem(t: IrType): boolean { // fs.Stats snapshots (`await Promise.all(paths.map(stat))`): immutable // refcounted handles with their own retain/release adapters. case "stats": + case "bigintStats": case "symbol": case "classval": case "moduleNs": diff --git a/packages/compiler/src/ir/runtime-features.test.ts b/packages/compiler/src/ir/runtime-features.test.ts index 7f53a45e6..feb00e079 100644 --- a/packages/compiler/src/ir/runtime-features.test.ts +++ b/packages/compiler/src/ir/runtime-features.test.ts @@ -42,6 +42,21 @@ describe("runtime feature snapshots", () => { expect(moduleUsesHttp2(mod)).toBe(false); }); + test("gates the BigIntStats unit on its member reads, not on bigint", () => { + const reads = module(); + reads.functions[0]!.body.push(call("bigstats.size")); + expect(moduleRuntimeFeatures(reads)).toMatchObject({ bigint: true, bigstats: true }); + + const storage = module(); + storage.functions[0]!.locals.push({ + id: "value", + name: "value", + mutable: false, + type: { kind: "bigint" }, + }); + expect(moduleRuntimeFeatures(storage)).toMatchObject({ bigint: true, bigstats: false }); + }); + test("keeps legacy HTTP/2 calls and isolated CA inspection out of the HTTP/2 unit", () => { const mod = module(); mod.functions[0]!.body.push(call("http2.streamNoop")); diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 43780b711..6f94e197c 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -852,12 +852,18 @@ const RUNTIME_FEATURE_RULES = { * queueMicrotask scheduling goes through the runtime's *_als entry * points, which carry the active stores into each callback. */ asyncContext: rule({ fns: [...ASYNC_CONTEXT_LIB_FNS] }), - /** scr_wasm.c converts i64 values through BigInt. */ + /** scr_wasm.c converts i64 values through BigInt, and BigIntStats reads + * produce bigint values. */ bigint: rule({ fns: ["process.hrtimeValue"], - prefixes: ["bigint.", "wasm."], + prefixes: ["bigint.", "wasm.", "bigstats."], types: ["bigint"], }), + /** BigIntStats member reads (`statSync(p, { bigint: true })` members) link + * scr_stats_bigint.c. The bigint feature is broader than this unit: every + * bigint value and wasm conversion turns it on, so gating the unit on it + * would link the getters into programs that never name a stat member. */ + bigstats: rule({ prefixes: ["bigstats."] }), childProcess: rule({ fns: [...CHILD_PROCESS_LIB_FNS], prefixes: CHILD_PROCESS_LIB_PREFIXES, @@ -1278,6 +1284,7 @@ function noRuntimeFeatures(): RuntimeFeatures { assert: false, asyncContext: false, bigint: false, + bigstats: false, childProcess: false, cipher: false, copying: false, 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/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index e83bf5c42..053502c6f 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -64,6 +64,7 @@ export function everyTypeChild(node: IrType, visit: (node: IrType) => boolean): case "searchParams": case "symbol": case "stats": + case "bigintStats": case "fileHandle": case "dir": case "spawnRes": diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..f591c6dd4 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -19,6 +19,7 @@ import type { import { arrayOf, iterableArmsElem, + BIGINT_STATS_T, BYTES_ELEMENT_NAME, BOOL, BYTES_U8, @@ -51,6 +52,7 @@ import { RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, shapeHasAccessorSlots, + STATS_T, STRING, typeEquals, typeKey, @@ -4239,7 +4241,14 @@ function validateFunction( } } if (!typeEquals(e.type, sig.result)) { - err(`libCall ${e.fn} must be ${sig.result.kind}, got ${e.type.kind}`, e.loc); + // The stat calls answer the SAME snapshot under either member table: + // their `bigint` option picks BigIntStats' reads over Stats'. + const statsVariant = + (e.fn === "fs.statSync" || e.fn === "fs.lstatSync" || e.fn === "fs.fstatSync") && + typeEquals(sig.result, STATS_T) && + typeEquals(e.type, BIGINT_STATS_T); + if (!statsVariant) + err(`libCall ${e.fn} must be ${sig.result.kind}, got ${e.type.kind}`, e.loc); } } @@ -4922,7 +4931,7 @@ function validateFunction( const ok = def && def.arms.length === 2 && - def.arms.some((a) => a.kind === "stats") && + def.arms.some((a) => a.kind === "stats" || a.kind === "bigintStats") && def.arms.some((a) => a.kind === "undefinedT"); if (!ok) { err(`libCall ${e.fn} must return the 'Stats | undefined' union`, e.loc); diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index d0d561b84..40f946b18 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -41,7 +41,7 @@ import { export const NODE_ISLAND_MIN_NODE = "24.13.1"; /** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */ -export const NODE_BRIDGE_ABI = 8; +export const NODE_BRIDGE_ABI = 9; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; 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/class-protocol-boundaries.test.ts b/packages/compiler/test/class-protocol-boundaries.test.ts index 62fcb15f5..6ed15a410 100644 --- a/packages/compiler/test/class-protocol-boundaries.test.ts +++ b/packages/compiler/test/class-protocol-boundaries.test.ts @@ -30,3 +30,58 @@ console.log(read(new Value()));`, rmSync(directory, { recursive: true, force: true }); } }); + +test("an accessor satisfying a declared data member keeps the target live", async () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-accessor-data-member-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync( + entry, + `interface Counter { size: number; label: string; } +function makeCounter(): Counter { + let current = 1; + return { + get size(): number { return current; }, + set size(next: number) { current = next; }, + label: "counter", + }; +} +const counter = makeCounter(); +counter.size = 10; +console.log(counter.size, counter.label, Object.keys(counter).join(","));`, + ); + const result = await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.ok).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a data property satisfying a declared accessor keeps the target live", async () => { + const directory = mkdtempSync(join(tmpdir(), "scriptc-data-accessor-member-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync( + entry, + `interface Live { readonly label: string; get size(): number; } +function makeLive(): Live { return { label: "counter", size: 3 }; } +console.log(makeLive().size);`, + ); + const result = await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.ok).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/native-constructors.test.ts b/packages/compiler/test/native-constructors.test.ts new file mode 100644 index 000000000..1aada9b0e --- /dev/null +++ b/packages/compiler/test/native-constructors.test.ts @@ -0,0 +1,145 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js"; + +const execFileAsync = promisify(execFile); +const target = nativeCodegenTarget(); +const helperSpec = target === null ? null : nativeHelperForTarget(target); +const helper = (() => { + if (helperSpec === null || process.platform === "win32") return null; + try { + const packageJson = createRequire(import.meta.url).resolve( + `${helperSpec.packageName}/package.json`, + ); + return join(dirname(packageJson), "bin", "scriptc-llvm-codegen"); + } catch { + return null; + } +})(); +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** A module whose constructor sets the value main returns. Functions after + * the constructor give split code generation and partitions something to + * divide. */ +function program(): string { + const lines = [ + "@value = internal global i32 0", + "@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] [{ i32, ptr, ptr } { i32 65535, ptr @init, ptr null }]", + "define internal void @init() {", + // Volatile, so the optimizer cannot evaluate the constructor at compile time. + " store volatile i32 42, ptr @value", + " ret void", + "}", + ]; + for (let index = 0; index < 8; index++) + lines.push( + `define internal i32 @step${index}(i32 %v) noinline {`, + ` %r = add i32 %v, ${index}`, + " ret i32 %r", + "}", + ); + lines.push( + "define i32 @main() {", + " %value = load i32, ptr @value", + ...Array.from({ length: 8 }, (_, index) => ` %s${index} = call i32 @step${index}(i32 0)`), + " ret i32 %value", + "}", + ); + return lines.join("\n") + "\n"; +} + +/** The objects' section names, on ELF targets (null elsewhere). */ +async function sectionNames(objects: string[]): Promise { + if (process.platform !== "linux") return null; + const names: string[] = []; + for (const object of objects) { + const { stdout } = await execFileAsync("readelf", ["-S", "-W", object]); + for (const match of stdout.matchAll(/^\s*\[\s*\d+\]\s+(\S+)/gm)) names.push(match[1]!); + } + return names; +} + +/** Links with lld when the C compiler driver can use it, as scriptc's Linux + * links do: unlike the BFD linker's default script, lld does not run .ctors + * entries from .init_array. */ +async function link(objects: string[], executable: string): Promise { + const cc = process.env["CC"] ?? "cc"; + try { + await execFileAsync(cc, ["-fuse-ld=lld", ...objects, "-o", executable]); + } catch { + await execFileAsync(cc, [...objects, "-o", executable]); + } +} + +async function exitCode(options: { + outputs: number; + split?: boolean; + name: string; +}): Promise<{ code: number; sections: string[] | null }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-constructors-")); + dirs.push(dir); + const input = join(dir, `${options.name}.ll`); + await writeFile(input, program()); + const outputs = Array.from({ length: options.outputs }, (_, index) => + join(dir, `${options.name}.${index}.o`), + ); + await execFileAsync(helper!, [ + "emit", + "--input", + input, + ...outputs.flatMap((output) => ["--output", output]), + "--filetype", + "obj", + "--target", + target!.llvmTriple, + "--opt-level", + "2", + "--relocation-model", + "pic", + "--diagnostic-format", + "json", + "--source-path", + "/src/program.ts", + ...(options.split === true ? ["--split", "codegen"] : []), + ]); + const sections = await sectionNames(outputs); + const executable = join(dir, options.name); + await link(outputs, executable); + try { + await execFileAsync(executable, []); + return { code: 0, sections }; + } catch (error) { + return { code: (error as { code: number }).code, sections }; + } +} + +/** The constructor ran, and on ELF it is in .init_array, never .ctors. */ +function expectConstructorRan(result: { code: number; sections: string[] | null }): void { + expect(result.code).toBe(42); + if (result.sections === null) return; + expect(result.sections).toContain(".init_array"); + expect(result.sections).not.toContain(".ctors"); +} + +describe.runIf(helper !== null)("program constructors", () => { + test("a constructor runs before main in a single object", async () => { + expectConstructorRan(await exitCode({ outputs: 1, name: "single" })); + }); + + test("a constructor runs before main with split code generation", async () => { + expectConstructorRan(await exitCode({ outputs: 3, split: true, name: "split" })); + }); + + test("a constructor runs before main across program partitions", async () => { + expectConstructorRan(await exitCode({ outputs: 3, name: "partitions" })); + }); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json new file mode 100644 index 000000000..faf99c163 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6818-node-island-mixed/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json new file mode 100644 index 000000000..16d9cc12b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-node-island-fenced/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json new file mode 100644 index 000000000..e2367ec2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6825-node-island-init-fence/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json new file mode 100644 index 000000000..a864c36e1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6826-node-island-undefined-args/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..8a467a09c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6829-node-island-declared-types/main.ts" + ], + "diags": [] +} 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/7015-path-platform-namespace-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json new file mode 100644 index 000000000..df48948fd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7015-path-platform-namespace-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7015-path-platform-namespace-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json new file mode 100644 index 000000000..521f14ae1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7016-union-field-delete.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7016-union-field-delete.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json new file mode 100644 index 000000000..822d75cf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7017-nonnullable-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7017-nonnullable-union.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json new file mode 100644 index 000000000..a74fe1704 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7018-bigint-stats.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7018-bigint-stats.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json new file mode 100644 index 000000000..15728a3da --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-accessor-member-kind-mismatch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-accessor-member-kind-mismatch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..bff29a8e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8516-empty-pattern-requires-object-coercible.js" + ], + "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/dyn-generator-method-value.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json new file mode 100644 index 000000000..1e51be568 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dyn-generator-method-value.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json new file mode 100644 index 000000000..82e7ed434 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/erased-never-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/erased-never-member-read.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/frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..7f5478af9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json new file mode 100644 index 000000000..a0771e0cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json new file mode 100644 index 000000000..04ed0d7a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-name-const-alias.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-name-const-alias.mjs" + ], + "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/optional-param-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json new file mode 100644 index 000000000..cd7d1520d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-param-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/optional-param-function-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json new file mode 100644 index 000000000..07aec4c5c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/path-namespace-spreads.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/path-namespace-spreads.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json new file mode 100644 index 000000000..2d6348884 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-cwd-value.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json new file mode 100644 index 000000000..165dc4ae4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-cwd-value.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-cwd-value.mjs" + ], + "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/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..ba45eb398 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global-computed.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json new file mode 100644 index 000000000..0bc118bd0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json new file mode 100644 index 000000000..5dd1fbc01 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tty-isatty-value-typed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json new file mode 100644 index 000000000..5aa2813a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tty-isatty-value.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tty-isatty-value.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json deleted file mode 100644 index 7ca950ac1..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/function-forms.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/function-forms.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json new file mode 100644 index 000000000..4cf28c2c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stdlib-snapshot-value-use.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json new file mode 100644 index 000000000..c388bced3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-spread-merge.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/union-spread-merge.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json new file mode 100644 index 000000000..dc30afdbc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/unmappable-declaration-initializer.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 a233becaf..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. } @@ -1441,6 +1502,11 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode } else { obj = ExportTemplate(isolate)->NewInstance(context).ToLocalChecked(); obj->SetInternalField(0, data); + // A template instance inherits from its implicit constructor's own + // prototype object; a native plain object is a plain object to island + // code (`Object.getPrototypeOf(o) === Object.prototype`, as jose's + // isObject checks), so it inherits Object.prototype directly. + obj->SetPrototypeV2(context, v8::Object::New(isolate)->GetPrototypeV2()).FromMaybe(false); } if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check(); Global &slot = g_exports[id]; @@ -1608,6 +1674,41 @@ Local ToV8(Isolate *isolate, Local context, const ScrNodeValue * } } +// Node's arrow header for an exception's throw site, recorded once on the +// error object (as Node decorates an error the first time it is thrown): +// ":\n\n", the caret under the +// message's columns with tabs kept. Printed by the runtime when the error +// ends the program uncaught (ErrorInfo's `arrow`). +Local ArrowKey(Isolate *isolate) { + return v8::Private::ForApi(isolate, Utf8(isolate, "scriptc.node-island.arrow")); +} + +void RecordArrow(Isolate *isolate, Local context, const TryCatch &try_catch, + Local exception) { + if (!exception->IsObject()) return; + Local error = exception.As(); + Local key = ArrowKey(isolate); + if (error->HasPrivate(context, key).FromMaybe(true)) return; + // Node decorates from the error's own message (its construction site, + // as Exception::CreateMessage answers for an Error with a stack), not + // from where the throw left the call. + Local message = v8::Exception::CreateMessage(isolate, exception); + if (message.IsEmpty()) message = try_catch.Message(); + if (message.IsEmpty()) return; + Local source; + if (!message->GetSourceLine(context).ToLocal(&source)) return; + std::string line = *String::Utf8Value(isolate, source); + std::string resource = *String::Utf8Value(isolate, message->GetScriptResourceName()); + int linenum = message->GetLineNumber(context).FromMaybe(0); + int start = message->GetStartColumn(context).FromMaybe(0); + int end = message->GetEndColumn(context).FromMaybe(start + 1); + std::string arrow = resource + ":" + std::to_string(linenum) + "\n" + line + "\n"; + for (int i = 0; i < start; i++) + arrow += (i < static_cast(line.size()) && line[i] == '\t') ? '\t' : ' '; + for (int i = start; i < end || i == start; i++) arrow += '^'; + (void)error->SetPrivate(context, key, Utf8(isolate, arrow.data(), arrow.size())); +} + // One entry from the runtime: the isolate scope, V8's stack limit for the // caller's stack, and the out-value scratch (reset by outermost entries). struct Op { @@ -1625,6 +1726,7 @@ struct Op { } if (try_catch.HasCaught() && !try_catch.HasTerminated()) { Local exception = try_catch.Exception(); + RecordArrow(e.isolate, e.context, try_catch, exception); try_catch.Reset(); FromV8(e.isolate, e.context, exception, out); } else { @@ -1886,6 +1988,9 @@ void ErrorInfo(uint32_t h, ScrNodeErrorInfo *out) { read("message", &out->message, &out->message_len, false); read("stack", &out->stack, &out->stack_len, true); read("code", &out->code, &out->code_len, true); + Local arrow; + if (error->GetPrivate(op.e.context, ArrowKey(op.e.isolate)).ToLocal(&arrow) && arrow->IsString()) + out->arrow = Scratch(Wtf8(op.e.isolate, arrow.As()), &out->arrow_len); } void DropHandle(uint32_t h) { 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-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 630a261b8..135894208 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -62,13 +62,18 @@ export const BASE_RUNTIME_SOURCES = [ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], + // BigIntStats member reads (`statSync(p, { bigint: true })` members). The + // bigstats gate is narrower than bigint, which every bigint value turns on. + ["scr_stats_bigint.c", "bigstats"], ["scr_regex.c", "regex"], ["scr_glob.c", "regex"], ["scr_idna.c", "regex"], ["scr_assert.c", any("assert", "regex", "symbol")], ["scr_inspect.c", "inspect"], ["scr_console_native.c", "inspect"], - ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], + // Island libCalls on native values typed by island declarations invoke + // through the checked-dynamic path (scr_node_island_handle.c). + ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch", "nodeIsland")], // Buffer.prototype methods called on checked-dynamic values. ["scr_bytes_dyn.c", all("dynBuffer", "dynInvoke")], // Builtin functions read as values in JavaScript dependencies. @@ -248,8 +253,13 @@ export function variantsFor(source) { defines: ["SCR_JS_TIER", "SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"], }); } - // util.inspect and instanceof of Node island handles. - if (source === "scr_inspect.c" || source === "scr_dyn_builtin_class.c") { + // util.inspect and instanceof of Node island handles; Node's report for + // an uncaught island error. + if ( + source === "scr_inspect.c" || + source === "scr_dyn_builtin_class.c" || + source === "scr_exception.c" + ) { variants.push({ id: "node-island", when: { nodeIsland: true }, diff --git a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts index a0107b78b..82b6d9e12 100644 --- a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts +++ b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts @@ -72,3 +72,12 @@ test("web streams and Node islands select an async build that defines both", () selected("scr_async.c", { webStreams: false, nodeIsland: false, workers: false }).defines, ).toEqual([]); }); + +test("the BigIntStats unit follows its own member-read gate, not bigint", () => { + const unit = matrix.runtime_units.find( + (u: { source: string }) => u.source === "scr_stats_bigint.c", + ); + // Every bigint value turns the bigint feature on; only the member reads + // name bigstats, so a bigint program keeps this unit out of its binary. + expect(unit.predicate).toBe("bigstats"); +}); 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_dyn.h b/packages/runtime/src/scr_dyn.h index 343930580..f4bc8db68 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1095,6 +1095,11 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value); * the programs that link it. */ ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_frozen(const ScrDyn *value); +/* Whether a record checked from `value` is frozen: `value` is. It answers + * for every value, where scr_dyn_is_frozen refuses values without a modeled + * integrity level: those are not frozen, as Object.freeze refuses them. A + * proxy is frozen exactly when its target is (the proxy invariants). */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value); ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index 9dcad5b67..f1c4348d7 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -9,6 +9,9 @@ * dummy returns); this file only stores, renders, and releases the value. */ #include "scr_runtime.h" +#ifdef SCR_NODE_ISLANDS +#include "scr_node_island.h" +#endif #include #include @@ -700,6 +703,16 @@ void scr_exc_print_uncaught(void) { /* Settle any runtime-internal stdout fragment before the stderr line when * both share an fd. JavaScript-visible writes already flush themselves. */ fflush(stdout); +#ifdef SCR_NODE_ISLANDS + /* An error thrown by Node island code: Node's own report (the throw + * site's source line, the stack, the Node.js version). */ + if (scr_exc_kind == SCR_EXC_OBJ && scr_error_is(scr_exc_payload) && + scr_ni_report_uncaught((ScrError *)scr_exc_payload)) { + scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); + return; + } +#endif fputs("Uncaught ", stderr); switch (scr_exc_kind) { case SCR_EXC_F64: { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2ecc99d9c..bee032446 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5060,6 +5060,9 @@ static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind); /* Node util.types reads internal brands, never user properties or prototypes. */ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (!value) return false; +#ifdef SCR_NODE_ISLANDS + if (scr_ni_is_handle(value)) return scr_ni_util_type_is(value, probe); +#endif #define PROBE(name) (probe->len == sizeof(name) - 1 && memcmp(probe->data, name, sizeof(name) - 1) == 0) if (PROBE("isAnyArrayBuffer")) return scr_buffer_storage_is(value); @@ -19267,26 +19270,29 @@ ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { return NULL; } -bool scr_dyn_is_frozen(const ScrDyn *value) { - if (scr_dyn_class_reflection_fence(value)) return false; +/* Object.isFrozen of `value` with `self` for the values it contains. A value + * without an integrity level the runtime models refuses with SC2020 when + * `refuse` is set, and otherwise answers false: Object.freeze refuses such + * a value too, so it is never frozen. */ +static bool scr_dyn_frozen_answer(const ScrDyn *value, bool (*self)(const ScrDyn *), bool refuse) { if (scr_dyn_published_object(value)) return true; bool answer; - if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; + if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, self, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(value); if (collection) return !scr_map_is_extensible(collection); - if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; + if (value->symbol_properties && !self(value->symbol_properties)) return false; if (scr_dyn_plain_fn(value)) { ScrDyn *table = scr_dyn_fn_existing_table(value); - answer = table && scr_dyn_is_frozen(table); + answer = table && self(table); scr_dyn_release(table); return answer; } if (value->kind == SCR_DYN_ARR) return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) && - (!value->v.arr.properties || scr_dyn_is_frozen(value->v.arr.properties)); + (!value->v.arr.properties || self(value->v.arr.properties)); if (value->kind == SCR_DYN_BYTES) return (value->v.bytes->is_data_view || value->v.bytes->len == 0) && value->v.bytes->properties && - scr_dyn_is_frozen(value->v.bytes->properties); + self(value->v.bytes->properties); if (value->kind == SCR_DYN_OBJ && !value->copied_from_native) { if (!value->non_extensible) return false; for (size_t i = 0; i < value->v.obj.len; i++) { @@ -19298,11 +19304,29 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { } if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NUM || value->kind == SCR_DYN_BOOL || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BIGINT || value->kind == SCR_DYN_SYMBOL) return true; + if (!refuse) return false; static const char message[] = "Object.isFrozen of this native value has no lowering"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return false; } +bool scr_dyn_is_frozen(const ScrDyn *value) { + if (scr_dyn_class_reflection_fence(value)) return false; + return scr_dyn_frozen_answer(value, scr_dyn_is_frozen, true); +} + +/* A record checked from `value` copies its integrity: whether `value` is + * frozen, answered for every value the check can meet. A proxy is frozen + * exactly when its target is (the proxy invariants). Object.prototype and + * class constructors, which Object.isFrozen fences, and native values + * without a modeled integrity level are not frozen. */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value) { + while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; + if (!value || value == scr_builtin_object_prototype) return false; + if (value->kind == SCR_DYN_FUNC && value->v.fn.class_obj && !value->v.fn.class_obj->static_data) return false; + return scr_dyn_frozen_answer(value, scr_dyn_checked_record_frozen, false); +} + double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..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) @@ -5126,6 +5170,12 @@ bool scr_process_is_tty(double fd) { return isatty((int)fd) != 0; } +/* tty.isatty as a function value (borrowed argument): a value that is not a + * number answers false and is never coerced, as Node's integer test does. */ +bool scr_process_is_tty_value(const ScrDyn *fd) { + return fd != NULL && fd->kind == SCR_DYN_NUM && scr_process_is_tty(fd->v.num); +} + /* Terminal geometry for process.stdout/stderr.columns/rows: ioctl(TIOCGWINSZ) on * the stream's fd, exactly Node's tty.WriteStream source of truth. A * non-TTY stream, or a terminal that refuses the ioctl, answers -1 and @@ -5238,33 +5288,10 @@ void scr_process_stdin_destroy(void) { * rejects (see the fsp section). scr_stats_dyn builds Node's Stats and * BigIntStats objects from the same snapshot. */ -/* The integers in the order Node's binding reports them. */ -enum { - SCR_STATS_DEV, - SCR_STATS_MODE, - SCR_STATS_NLINK, - SCR_STATS_UID, - SCR_STATS_GID, - SCR_STATS_RDEV, - SCR_STATS_BLKSIZE, - SCR_STATS_INO, - SCR_STATS_SIZE, - SCR_STATS_BLOCKS, - SCR_STATS_FIELDS -}; - -/* The timestamps, each kept as seconds + nanoseconds. */ -enum { SCR_STATS_ATIME, SCR_STATS_MTIME, SCR_STATS_CTIME, SCR_STATS_BIRTHTIME }; - -struct ScrStats { - size_t rc; - bool is_file; - bool is_dir; - bool is_symlink; /* lstat only — a followed stat never sees one */ - uint64_t fields[SCR_STATS_FIELDS]; /* blocks counts 512-byte units */ - int64_t sec[4]; - int64_t nsec[4]; -}; +/* The layout (the field enum, the timestamp enum and the struct) lives in + * scr_stats_private.h so scr_stats_bigint.c can read the same snapshot + * without this unit carrying its code. */ +#include "scr_stats_private.h" ScrStats *scr_stats_retain(ScrStats *s) { if (s->rc != SIZE_MAX) s->rc++; diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..bc0184573 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -386,6 +386,7 @@ double scr_atomics_notify_dyn(const ScrDyn *, double, double); * non-TTY streams expose undefined (SEMANTICS.md). stdin_destroy is a * documented no-op. */ bool scr_process_is_tty(double fd); +bool scr_process_is_tty_value(const ScrDyn *fd); void scr_process_stdin_destroy(void); void scr_process_stdin_set_raw_mode(bool raw); @@ -477,6 +478,32 @@ double scr_stats_atime(ScrStats *s); double scr_stats_mtime(ScrStats *s); double scr_stats_ctime(ScrStats *s); double scr_stats_birthtime(ScrStats *s); +/* BigIntStats members (scr_stats_bigint.c): every number member of Stats + * as a bigint (+1), the `*Ns` nanosecond members, and the Date fields' + * time values from the truncated `*Ms` member. */ +typedef struct ScrBigInt ScrBigInt; +ScrBigInt *scr_stats_big_dev(ScrStats *s); +ScrBigInt *scr_stats_big_mode(ScrStats *s); +ScrBigInt *scr_stats_big_nlink(ScrStats *s); +ScrBigInt *scr_stats_big_uid(ScrStats *s); +ScrBigInt *scr_stats_big_gid(ScrStats *s); +ScrBigInt *scr_stats_big_rdev(ScrStats *s); +ScrBigInt *scr_stats_big_blksize(ScrStats *s); +ScrBigInt *scr_stats_big_ino(ScrStats *s); +ScrBigInt *scr_stats_big_size(ScrStats *s); +ScrBigInt *scr_stats_big_blocks(ScrStats *s); +ScrBigInt *scr_stats_big_atime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_mtime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_ctime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_birthtime_ns(ScrStats *s); +ScrBigInt *scr_stats_big_atime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_mtime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_ctime_ms(ScrStats *s); +ScrBigInt *scr_stats_big_birthtime_ms(ScrStats *s); +double scr_stats_big_atime(ScrStats *s); +double scr_stats_big_mtime(ScrStats *s); +double scr_stats_big_ctime(ScrStats *s); +double scr_stats_big_birthtime(ScrStats *s); /* fstat without throwing: +1, or NULL with *error set to the errno. */ ScrStats *scr_stats_of_fd(int fd, int *error); /* Node's Stats (or BigIntStats) object for the snapshot: own fields in the diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index 969477b58..830b67193 100644 --- a/packages/runtime/src/scr_node_bridge.h +++ b/packages/runtime/src/scr_node_bridge.h @@ -17,7 +17,7 @@ #include #include -#define SCR_NODE_BRIDGE_ABI 8u +#define SCR_NODE_BRIDGE_ABI 9u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -149,6 +149,11 @@ typedef struct ScrNodeErrorInfo { uint32_t kind; const char *name, *message, *stack, *code; size_t name_len, message_len, stack_len, code_len; + /* Node's arrow header for the throw site (":\n\n"), recorded when the error was thrown out of island + * code; NULL when none. */ + const char *arrow; + size_t arrow_len; } ScrNodeErrorInfo; /* What the runtime provides to the bridge. */ 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 1a96754a8..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); @@ -87,6 +97,13 @@ ScrDyn *scr_ni_call_method(ScrDyn *obj, const char *key, size_t len, ScrDyn *con size_t argc); ScrDyn *scr_ni_construct(ScrDyn *ctor, ScrDyn *const *args, size_t argc); ScrStr *scr_ni_typeof(ScrDyn *value); +/* util.types.(value) answered by Node for an island handle. */ +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe); +/* Array.isArray of a dyn value, island arrays included. */ +bool scr_ni_is_array(ScrDyn *value); +/* Prints Node's uncaught-exception report for an error thrown by island + * code (true), or nothing (false: not an island error). */ +bool scr_ni_report_uncaught(ScrError *error); int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_ni_is_handle(const ScrDyn *value); /* ToPrimitive of an island object (hint SCR_DYN_HINT_*), run by V8: @@ -195,6 +212,7 @@ ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args); ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args); void scr_node_island_define_field(ScrDyn *self, ScrStr *key, ScrDyn *value); ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key); +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key); bool scr_node_island_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_node_island_instanceof_global(ScrDyn *value, ScrStr *name); ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 631a19b8c..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; @@ -1457,6 +1461,58 @@ void scr_ni_handles_install(void) { scr_atexit(nh_cleanup); } +/* ── NI-4d: util.types, Array.isArray and uncaught reports ─────────────── */ + +static ScrDyn *nh_util_types = NULL; /* Node's util.types (held to exit) */ + +static void nh_util_types_cleanup(void) { + scr_dyn_release(nh_util_types); + nh_util_types = NULL; +} + +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe) { + if (nh_util_types == NULL) { + ScrStr *path = scr_str_new("node:util", 9); + ScrDyn *util = scr_ni_require(path); + scr_str_release(path); + if (util == NULL) return false; + nh_util_types = scr_ni_get_str(util, "types", 5); + if (nh_util_types != NULL) scr_atexit(nh_util_types_cleanup); + scr_dyn_release(util); + if (nh_util_types == NULL) return false; + } + ScrDyn *args[1] = {(ScrDyn *)value}; + ScrDyn *r = scr_ni_call_method(nh_util_types, probe->data, probe->len, args, 1); + if (r == NULL) return false; + bool answer = r->kind == SCR_DYN_BOOL && r->v.b; + scr_dyn_release(r); + return answer; +} + +bool scr_ni_is_array(ScrDyn *value) { + if (value == NULL) return false; + if (value->kind == SCR_DYN_ARR) return true; + if (!scr_ni_is_handle(value)) return value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value); + return (((ScrNodeRef *)value->v.handle.ptr)->flags & SCR_NVF_ARRAY) != 0; +} + +bool scr_ni_report_uncaught(ScrError *error) { + ScrNodeRef *ref = nh_pinned(error, NH_PIN_ERROR); + const ScrNodeBridge *bridge = scr_ni_bridge_loaded(); + if (ref == NULL || ref->id == 0 || bridge == NULL) return false; + ScrNodeErrorInfo info; + bridge->error_info(ref->id, &info); + if (info.stack == NULL) return false; + if (info.arrow != NULL && info.arrow_len > 0) { + fwrite(info.arrow, 1, info.arrow_len, stderr); + fputs("\n\n", stderr); + } + fwrite(info.stack, 1, info.stack_len, stderr); + fprintf(stderr, "\n\nNode.js v%s\n", bridge->node_version); + fflush(stderr); + return true; +} + /* ── NI-4a: libCall entry points of compiled island code ───────────────── * The compiler (frontend/lowering/lower-node-island.ts) lowers member * operations on island values to `nodeIsland.*` libCalls, which pass @@ -1473,28 +1529,61 @@ static size_t ni_libcall_args(ScrDyn *args, ScrDyn *const **items) { return args->v.arr.len; } +/* Routing follows the value: a value typed by an island's declarations + * may still be a native object (an island interface annotating a native + * literal, a native error handed to an island callback). Those take the + * ordinary checked-dynamic operations instead of the bridge. */ +ScrDyn *scr_dyn_reflect_get(ScrDyn *target, ScrDyn *raw_key, ScrDyn *receiver); + +static bool ni_lc_island(const ScrDyn *v) { return v != NULL && scr_ni_is_handle(v); } + +/* A NUL-terminated copy of `key` for the dyn invoke path (heap only for long names). */ +static ScrDyn *ni_lc_native_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *const *items, size_t argc) { + char small[128]; + char *name = key->len < sizeof small ? small : malloc(key->len + 1); + if (name == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(name, key->data, key->len); + name[key->len] = '\0'; + ScrDyn *result = scr_dyn_invoke(obj, name, items, argc, name); + if (name != small) free(name); + return result; +} + ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key) { - return scr_ni_get_str(obj, key->data, key->len); + if (ni_lc_island(obj)) return scr_ni_get_str(obj, key->data, key->len); + ScrDyn *k = scr_dyn_new_str(key); + ScrDyn *value = scr_dyn_reflect_get(obj, k, obj); + scr_dyn_release(k); + return value; +} + +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key) { + if (ni_lc_island(obj)) return scr_ni_get(obj, key); + return scr_dyn_reflect_get(obj, key, obj); } void scr_node_island_set(ScrDyn *obj, ScrStr *key, ScrDyn *value) { - (void)scr_ni_set_str(obj, key->data, key->len, value); + if (ni_lc_island(obj)) (void)scr_ni_set_str(obj, key->data, key->len, value); + else scr_dyn_key_set(obj, key, value); } void scr_node_island_set_key(ScrDyn *obj, ScrDyn *key, ScrDyn *value) { - (void)scr_ni_set(obj, key, value); + if (ni_lc_island(obj)) (void)scr_ni_set(obj, key, value); + else scr_dyn_key_set_computed(obj, key, value); } ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call_method(obj, key->data, key->len, items, argc); + if (ni_lc_island(obj)) return scr_ni_call_method(obj, key->data, key->len, items, argc); + return ni_lc_native_invoke(obj, key, items, argc); } ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call(fn, NULL, items, argc); + if (ni_lc_island(fn)) return scr_ni_call(fn, NULL, items, argc); + return scr_dyn_call(fn, items, argc, "value"); } ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args) { @@ -1505,6 +1594,12 @@ ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args) { } ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args) { + if (!ni_lc_island(ctor)) { + ScrStr *what = scr_str_new("value", 5); + ScrDyn *result = scr_dyn_construct(ctor, args, what); + scr_str_release(what); + return result; + } ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); return scr_ni_construct(ctor, items, argc); diff --git a/packages/runtime/src/scr_stats_bigint.c b/packages/runtime/src/scr_stats_bigint.c new file mode 100644 index 000000000..f9bf38fdd --- /dev/null +++ b/packages/runtime/src/scr_stats_bigint.c @@ -0,0 +1,99 @@ +/* scriptc runtime — BigIntStats member reads (scr_lib.c's stat snapshot, + * read through Node's bigint member table). Linked only into programs whose + * IR reads a BigIntStats member (the compiler's `bigint` runtime feature), so + * a program that never asks for `statSync(p, { bigint: true })` members links + * none of this. + * + * Node's BigIntStats answers a bigint for EVERY number member of Stats: the + * integers, the `*Ms` millisecond members (the truncated nanosecond + * quotient, as the binding computes them) and the BigIntStats-only `*Ns` + * nanosecond members (sec * 1e9 + nsec, exactly as scr_lib.c's dynamic + * conversion does). The four Date members are built from the `*Ms` member, + * as Node's getters do (new Date(Math.round(Number(this.atimeMs)))): the + * truncated millisecond, where Stats rounds its fractional one. + * Every bigint here is answered +1 like the rest of the snapshot accessors. */ +#include "scr_bigint.h" +#include "scr_lib.h" +#include "scr_number.h" +#include "scr_stats_private.h" + +/* The binding hands its BigInt64Array signed values. */ +static ScrBigInt *scr_stats_big_field(const ScrStats *s, size_t index) { + return scr_bigint_from_i64((int64_t)s->fields[index]); +} + +ScrBigInt *scr_stats_big_dev(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_DEV); } +ScrBigInt *scr_stats_big_mode(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_MODE); } +ScrBigInt *scr_stats_big_nlink(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_NLINK); } +ScrBigInt *scr_stats_big_uid(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_UID); } +ScrBigInt *scr_stats_big_gid(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_GID); } +ScrBigInt *scr_stats_big_rdev(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_RDEV); } +ScrBigInt *scr_stats_big_blksize(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_BLKSIZE); } +ScrBigInt *scr_stats_big_ino(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_INO); } +ScrBigInt *scr_stats_big_size(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_SIZE); } +ScrBigInt *scr_stats_big_blocks(ScrStats *s) { return scr_stats_big_field(s, SCR_STATS_BLOCKS); } + +/* nsFromTimeSpecBigInt: sec * 1_000_000_000 + nsec in exact bigint + * arithmetic (a 64-bit product would overflow far outside the range a + * filesystem timestamp can hold, but exactness is free here). */ +static ScrBigInt *scr_stats_big_ns_of(const ScrStats *s, int which) { + ScrBigInt *sec = scr_bigint_from_i64(s->sec[which]); + ScrBigInt *nsec = scr_bigint_from_i64(s->nsec[which]); + ScrBigInt *billion = scr_bigint_from_i64(1000000000); + ScrBigInt *scaled = scr_bigint_mul(sec, billion); + ScrBigInt *total = scr_bigint_add(scaled, nsec); + scr_bigint_release(sec); + scr_bigint_release(nsec); + scr_bigint_release(billion); + scr_bigint_release(scaled); + return total; +} + +/* The millisecond member: the nanosecond count divided by 1e6 (bigint + * division truncates toward zero). */ +static ScrBigInt *scr_stats_big_ms_of(const ScrStats *s, int which) { + ScrBigInt *total = scr_stats_big_ns_of(s, which); + ScrBigInt *million = scr_bigint_from_i64(1000000); + ScrBigInt *ms = scr_bigint_div(total, million); + scr_bigint_release(total); + scr_bigint_release(million); + return ms; +} + +ScrBigInt *scr_stats_big_atime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_ATIME); +} +ScrBigInt *scr_stats_big_mtime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_MTIME); +} +ScrBigInt *scr_stats_big_ctime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_CTIME); +} +ScrBigInt *scr_stats_big_birthtime_ns(ScrStats *s) { + return scr_stats_big_ns_of(s, SCR_STATS_BIRTHTIME); +} + +ScrBigInt *scr_stats_big_atime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_ATIME); +} +ScrBigInt *scr_stats_big_mtime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_MTIME); +} +ScrBigInt *scr_stats_big_ctime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_CTIME); +} +ScrBigInt *scr_stats_big_birthtime_ms(ScrStats *s) { + return scr_stats_big_ms_of(s, SCR_STATS_BIRTHTIME); +} + +static double scr_stats_big_date_of(const ScrStats *s, int which) { + ScrBigInt *ms = scr_stats_big_ms_of(s, which); + double date = scr_date_new_ms(scr_bigint_to_f64(ms)); + scr_bigint_release(ms); + return date; +} + +double scr_stats_big_atime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_ATIME); } +double scr_stats_big_mtime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_MTIME); } +double scr_stats_big_ctime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_CTIME); } +double scr_stats_big_birthtime(ScrStats *s) { return scr_stats_big_date_of(s, SCR_STATS_BIRTHTIME); } diff --git a/packages/runtime/src/scr_stats_private.h b/packages/runtime/src/scr_stats_private.h new file mode 100644 index 000000000..9897fe15b --- /dev/null +++ b/packages/runtime/src/scr_stats_private.h @@ -0,0 +1,43 @@ +/* scriptc runtime — the stat snapshot's layout (scr_lib.c). PRIVATE: the + * enums index fields[] and the two time arrays exactly as scr_lib.c fills + * them. Only scr_lib.c itself and the BigIntStats member reads + * (scr_stats_bigint.c) may include this; everything else reads the snapshot + * through scr_lib.h's accessors. Keeping the layout in one header is what + * lets the bigint members live in their own unit, so a program that never + * reads them links none of that code. */ +#ifndef SCR_STATS_PRIVATE_H +#define SCR_STATS_PRIVATE_H + +#include +#include +#include + +/* The integers in the order Node's binding reports them. */ +enum { + SCR_STATS_DEV, + SCR_STATS_MODE, + SCR_STATS_NLINK, + SCR_STATS_UID, + SCR_STATS_GID, + SCR_STATS_RDEV, + SCR_STATS_BLKSIZE, + SCR_STATS_INO, + SCR_STATS_SIZE, + SCR_STATS_BLOCKS, + SCR_STATS_FIELDS +}; + +/* The timestamps, each kept as seconds + nanoseconds. */ +enum { SCR_STATS_ATIME, SCR_STATS_MTIME, SCR_STATS_CTIME, SCR_STATS_BIRTHTIME }; + +struct ScrStats { + size_t rc; + bool is_file; + bool is_dir; + bool is_symlink; /* lstat only — a followed stat never sees one */ + uint64_t fields[SCR_STATS_FIELDS]; /* blocks counts 512-byte units */ + int64_t sec[4]; + int64_t nsec[4]; +}; + +#endif /* SCR_STATS_PRIVATE_H */ diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index a9138cedc..d712ebb84 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1756,6 +1756,36 @@ static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, Sc e->cb = cb; return cb; } + /* A forward access past the cached ASCII run (a scanner taking slices at + * increasing positions of a mostly ASCII text) grows the run over the + * ASCII bytes up to it. When a non-ASCII character ends the run first, a + * nearby access walks from the run's end or the hot cursor, whichever is + * nearer, and a new run starts where the walk lands, so the accesses after + * it map by arithmetic again. */ + if (u16 >= e->ascii_cu) { + if (scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) { + e->cb = e->ascii_cb + (u16 - e->ascii_cu); + e->cu = u16; + *mid = false; + return e->cb; + } + size_t run_cu = e->ascii_cu + (e->ascii_end - e->ascii_cb); + if (e->ascii_end < s->len && u16 - run_cu <= SCR_SIDX_NEAR_UNITS) { + size_t cu = run_cu, cb = e->ascii_end; + if (e->cb <= s->len && e->cu >= cu && e->cu <= u16) { + cu = e->cu; + cb = e->cb; + } + size_t at = scr_sidx_walk_from(s, e, cu, cb, u16, mid, false); + if (!*mid && at < s->len && (unsigned char)s->data[at] < 0x80) { + e->ascii_cu = e->cu; + e->ascii_cb = at; + e->ascii_end = at; + (void)scr_sidx_ascii_grow(s, e, at); + } + return at; + } + } /* Within a short distance of the hot cursor (a scanner * taking slices near its position), walk from that cursor: no index * search, extension or ASCII-run growth is cheaper. */ @@ -1785,12 +1815,6 @@ static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, Sc return cb; } } - if (u16 >= e->ascii_cu && scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) { - e->cb = e->ascii_cb + (u16 - e->ascii_cu); - e->cu = u16; - *mid = false; - return e->cb; - } scr_sidx_extend_to_u16(s, e, u16); /* Extending a far-end lookup can just have proved identity. Do not * materialize an index that the identity fast path will never consult. */ 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/runtime/test/test_string.c b/packages/runtime/test/test_string.c index 5a3bdfe5f..3bce02761 100644 --- a/packages/runtime/test/test_string.c +++ b/packages/runtime/test/test_string.c @@ -927,6 +927,54 @@ static void local_navigation_asserts(void) { scr_sidx_test_reset_cache(); } +/* A scanner reading a mostly ASCII text front to back (positioned + * startsWith and substrings at increasing positions, with a non-ASCII + * character every 300 bytes) maps its positions through the ASCII run, so + * it walks only across the non-ASCII characters. */ +static void forward_scan_asserts(void) { + enum { ASCII = 300, UNITS = ASCII + 1, REPS = 100 }; + char piece[ASCII + 2]; + for (size_t i = 0; i < ASCII; i++) piece[i] = (char)('a' + i % 26); + memcpy(piece + ASCII, "\xC3\xA9", 2); /* é: one unit */ + ScrStr *unit = scr_str_new(piece, sizeof piece); + ScrStr *s = scr_str_repeat(unit, REPS); + scr_str_release(unit); + scr_sidx_test_reset_cache(); + if (scr_str_utf16_len(s) != UNITS * REPS) sidx_fail("forward scan length"); + ScrStr *needle = scr_str_new("ab", 2); + scr_sidx_test_reset_steps(); + for (size_t at = 0; at + 2 <= UNITS * REPS; at += 5) { + size_t offset = at % UNITS; + bool expected = offset + 1 < ASCII && offset % 26 == 0; + if (scr_str_starts_with_from(s, needle, (double)at) != expected) { + sidx_fail("forward startsWith"); + break; + } + char want[4]; + size_t n = 0; + for (size_t k = at; k < at + 2; k++) { + size_t o = k % UNITS; + if (o < ASCII) want[n++] = (char)('a' + o % 26); + else { + want[n++] = '\xC3'; + want[n++] = '\xA9'; + } + } + ScrStr *part = scr_str_substring(s, (double)at, (double)(at + 2)); + bool same = part->len == n && memcmp(part->data, want, n) == 0; + scr_str_release(part); + if (!same) { + sidx_fail("forward substring"); + break; + } + } + if (scr_sidx_test_walk_steps() > REPS * 16) + sidx_fail("forward reads walked between non-ASCII characters"); + scr_str_release(needle); + scr_str_release(s); + scr_sidx_test_reset_cache(); +} + /* An entry borrowed for a receiver must remain its entry when a second * string needs indexing. Fill the cursor tier so the needle would evict * precisely the receiver under a receiver-first lookup order. */ @@ -1244,6 +1292,7 @@ int main(int argc, char **argv) { index_activity_asserts(); sparse_slice_churn_asserts(); local_navigation_asserts(); + forward_scan_asserts(); positioned_suffix_eviction_asserts(); #endif 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/6818-node-island-mixed/main.ts b/tests/corpus/6818-node-island-mixed/main.ts new file mode 100644 index 000000000..aa3c3076c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/main.ts @@ -0,0 +1,51 @@ +// @requires-libnode +// A Node island package used from both sides: a natively compiled package +// requires it; island-declared interfaces type native objects too (member +// operations follow the value, not the declared type); options built with +// spread and method members cross in by reference; a `string | Buffer` +// member reads back from an island object; a rejection's error is read in +// a native callback. +import { localName, swapped, twice } from "wraplib"; +import * as lib from "islandlib"; +import { + countFiles, + describeFile, + blob, + text, + render, + failLater, + type Files, + type File, + type FailError, +} from "islandlib"; + +console.log("twice", twice(5)); +console.log("swapped", swapped(), localName(), "ns call", lib.getValue()); + +const files: Files = {}; +files["a.txt"] = { mode: 420, data: "alpha" }; +files["b.txt"] = blob("beta"); +console.log("files", countFiles(files), files["a.txt"]!.mode, Object.keys(files).join(",")); +const local: File = files["a.txt"]!; +console.log("local", local.mode, describeFile(local), describeFile(files["b.txt"]!)); + +const fromIsland = blob("gamma"); +const data = fromIsland.data; +console.log("buffer", typeof data === "string" ? data : data.toString("utf8"), describeFile(text("delta"))); + +const base = { name: "job", retries: 1 }; +console.log( + "render", + render({ + ...base, + retries: 3, + format(name: string): string { + return name.toUpperCase(); + }, + }), +); + +failLater("nope").catch((e: unknown) => { + const err = e as FailError; + console.log("caught", err.message, err.code, (e as { message?: string }).message); +}); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts new file mode 100644 index 000000000..9cbf018ed --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts @@ -0,0 +1,29 @@ +export declare class Counter { + constructor(start: number); + value: number; + inc(): number; +} +export interface File { + mode: number; + data: string | Buffer; +} +export interface Files { + [path: string]: File; +} +export interface RenderOptions { + name: string; + retries: number; + format(name: string): string; +} +export interface FailError extends Error { + code: string; +} +export declare function makeCounter(start: number): Counter; +export declare function countFiles(files: Files): number; +export declare function describeFile(file: File): string; +export declare function blob(text: string): File; +export declare function text(text: string): File; +export declare function render(opts: RenderOptions): string; +export declare function failLater(message: string): Promise; +export declare const value: number; +export declare function getValue(): number; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js new file mode 100644 index 000000000..b7c662ac2 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js @@ -0,0 +1,22 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. +const { createRequire } = require("node:module"); +const load = createRequire(__filename); +const { Counter } = load(["./lib", "counter"].join("/")); + +exports.makeCounter = (start) => new Counter(start); +// A method that reads its receiver. +exports.value = 7; +exports.getValue = function () { + return this.value; +}; +exports.Counter = Counter; +// A files map: native callers build their own with the same interface. +exports.countFiles = (files) => Object.keys(files).length; +exports.describeFile = (file) => `${file.mode}:${file.data.toString("utf8")}`; +exports.blob = (text) => ({ mode: 420, data: Buffer.from(text) }); +exports.text = (text) => ({ mode: 384, data: text }); +// Takes options built from spread and method members. +exports.render = (opts) => `${opts.name}/${opts.retries}/${opts.format(opts.name)}`; +// Settles a promise with an Error the caller inspects in .catch. +exports.failLater = (message) => Promise.reject(Object.assign(new Error(message), { code: "EFAIL" })); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js new file mode 100644 index 000000000..def5f80e4 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js @@ -0,0 +1,10 @@ +"use strict"; +class Counter { + constructor(start) { + this.value = start; + } + inc() { + return ++this.value; + } +} +exports.Counter = Counter; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json new file mode 100644 index 000000000..b3a5a4e8b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json @@ -0,0 +1 @@ +{ "name": "islandlib", "version": "2.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts new file mode 100644 index 000000000..398cd5446 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts @@ -0,0 +1,3 @@ +export declare function twice(start: number): number; +export declare function swapped(): number; +export declare function localName(): string; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js new file mode 100644 index 000000000..939e45521 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js @@ -0,0 +1,15 @@ +"use strict"; +// A plain package scriptc compiles natively that requires an island package. +const islandlib = require("islandlib"); + +// A binding the module reassigns after requiring: reads follow the binding. +let swappable = require("islandlib"); +swappable = { makeCounter: () => ({ inc: () => 100 }) }; +exports.swapped = () => swappable.makeCounter(0).inc(); +exports.localName = require("./local").localName; + +exports.twice = function twice(start) { + const c = islandlib.makeCounter(start); + c.inc(); + return c.inc(); +}; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js new file mode 100644 index 000000000..7ac847936 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js @@ -0,0 +1,7 @@ +"use strict"; +// A module function named require: calls of it are its own. +function require(name) { + return { name: `local ${name}` }; +} +const fake = require("islandlib"); +exports.localName = () => fake.name; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json new file mode 100644 index 000000000..5679ba18c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json @@ -0,0 +1 @@ +{ "name": "wraplib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "islandlib": "2.0.0" } } diff --git a/tests/corpus/6819-node-island-fenced/main.ts b/tests/corpus/6819-node-island-fenced/main.ts new file mode 100644 index 000000000..7416c5ce4 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/main.ts @@ -0,0 +1,10 @@ +// @requires-libnode +// A package scriptc compiles natively only with runtime fences that need +// a mutable object model (getters over live bindings, a constructor +// function with prototype methods) runs in a Node island automatically. +import { bump, current, Tally } from "fencedkit"; +import kit from "fencedkit"; + +console.log(current, bump(), bump(), kit.current); +const t = new Tally(1); +console.log(t.add(2) === t, t.add(3).total, t instanceof Tally); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts new file mode 100644 index 000000000..817efabc9 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts @@ -0,0 +1,7 @@ +export declare const current: number; +export declare function bump(): number; +export declare class Tally { + constructor(start: number); + total: number; + add(n: number): Tally; +} diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js new file mode 100644 index 000000000..a6dac5623 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js @@ -0,0 +1,29 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings, +// plus a constructor function with prototype methods (R2.1). scriptc +// compiles it natively only with runtime fences, so it runs in a Node +// island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Tally: () => Tally, + current: () => current, + bump: () => bump, +}); +module.exports = src_exports; +let current = 0; +function bump() { + current += 1; + return current; +} +function Tally(start) { + this.total = start; +} +Tally.prototype.add = function (n) { + this.total += n; + return this; +}; +0 && (module.exports = { Tally, bump, current }); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json new file mode 100644 index 000000000..51fc5639c --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json @@ -0,0 +1 @@ +{ "name": "fencedkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6825-node-island-init-fence/main.ts b/tests/corpus/6825-node-island-init-fence/main.ts new file mode 100644 index 000000000..7e5ba8391 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/main.ts @@ -0,0 +1,16 @@ +// @requires-libnode +// A package whose top-level code always hits a runtime fence (pump's +// `var` function exported by reference), reached through a natively +// compiled package, runs in a Node island automatically; a `string | +// Buffer` member read from an island object decodes with toString("utf8"); +// a CommonJS helper required on both sides is duplicated quietly (its +// exports are declarations). +import { pack } from "tarlike"; +import { fromText, fromBytes, onceTotal } from "bloblike"; + +console.log("pack", pack([1, 2, 3])); +for (const blob of [fromText("plain"), fromBytes("bytes")]) { + const data = blob.data; + console.log("blob", blob.mode.toString(8), data.toString("utf8"), typeof data === "string"); +} +console.log("once", onceTotal([4, 5])); diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts new file mode 100644 index 000000000..3858e4a0c --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts @@ -0,0 +1,8 @@ +export declare class FileBlob { + constructor(data: string | Buffer); + data: string | Buffer; + mode: number; +} +export declare function fromText(text: string): FileBlob; +export declare function fromBytes(text: string): FileBlob; +export declare function onceTotal(values: number[]): number; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js new file mode 100644 index 000000000..31ccad123 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js @@ -0,0 +1,19 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. It +// also requires the CommonJS helper the native side uses. +const { createRequire } = require("node:module"); +const once = createRequire(__filename)(["once", "like"].join("")); + +class FileBlob { + constructor(data) { + this.data = data; + this.mode = 0o644; + } +} +exports.FileBlob = FileBlob; +exports.fromText = (text) => new FileBlob(text); +exports.fromBytes = (text) => new FileBlob(Buffer.from(text)); +exports.onceTotal = (values) => { + const sum = once(() => values.reduce((a, b) => a + b, 0)); + return sum() + sum(); +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json new file mode 100644 index 000000000..b9db808e4 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json @@ -0,0 +1 @@ +{ "name": "bloblike", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js new file mode 100644 index 000000000..b1f19e69a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js @@ -0,0 +1,17 @@ +// A CommonJS helper both native code and an island require: its exports +// are declarations, so each side's copy is unobservable. +module.exports = once; +once.strict = onceStrict; +function once(fn) { + var f = function () { + if (f.called) return f.value; + f.called = true; + return (f.value = fn.apply(this, arguments)); + }; + f.called = false; + return f; +} +function onceStrict(fn) { + var f = once(fn); + return f; +} diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json new file mode 100644 index 000000000..852d69c5a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json @@ -0,0 +1 @@ +{ "name": "oncelike", "version": "1.4.0", "main": "once.js" } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js new file mode 100644 index 000000000..01bf4da9e --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js @@ -0,0 +1,17 @@ +// pump's shape: a function stored in a module-level `var` and exported by +// reference after it is defined. +var once = require("oncelike"); + +var noop = function () {}; + +var pumpish = function () { + var steps = Array.prototype.slice.call(arguments); + var done = once(typeof steps[steps.length - 1] === "function" ? steps.pop() : noop); + var total = steps.reduce(function (acc, n) { + return acc + n; + }, 0); + done(total); + return total; +}; + +module.exports = pumpish; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json new file mode 100644 index 000000000..40b2cc4e2 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json @@ -0,0 +1 @@ +{ "name": "pumplike", "version": "3.0.3", "main": "index.js", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts new file mode 100644 index 000000000..9de17307a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts @@ -0,0 +1 @@ +export declare function pack(sizes: number[]): string; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js new file mode 100644 index 000000000..22eb47020 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js @@ -0,0 +1,13 @@ +"use strict"; +// A typed package scriptc compiles natively that requires pump's shape. +const pump = require("pumplike"); +const once = require("oncelike"); + +exports.pack = function pack(sizes) { + let reported = 0; + const report = once((n) => { + reported = n; + }); + const total = pump(...sizes, report); + return `${total}/${reported}`; +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json new file mode 100644 index 000000000..877fe5f27 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json @@ -0,0 +1 @@ +{ "name": "tarlike", "version": "2.1.5", "main": "index.js", "types": "index.d.ts", "dependencies": { "pumplike": "3.0.3", "oncelike": "1.4.0" } } diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts new file mode 100644 index 000000000..5f6fb53e9 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -0,0 +1,85 @@ +// @requires-libnode +// Arguments crossing into a Node island keep JavaScript's exact shape: +// explicit `undefined` arguments count (arguments.length, default +// parameters), an omitted optional field is absent while one holding +// `undefined` is present (`in`, hasOwnProperty, Object.keys), and native +// number arrays arrive as arrays. +import { + probe, + defaults, + version, + inspect, + nodeVersionFor, + cwd, + envValue, + plain, + type Config, +} from "argprobe"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface Settings { + nodeVersion?: string; +} +function settingsFor(flag: boolean): Settings | undefined { + return flag ? { nodeVersion: "20.x" } : undefined; +} + +console.log(probe()); +console.log(probe(undefined)); +console.log(probe("a", undefined)); +console.log(probe("a", {}, undefined)); +console.log(probe("a", { nodeVersion: undefined }, undefined, [18, 20, 22])); +const settings = settingsFor(false); +console.log(probe("a", { nodeVersion: settings?.nodeVersion }, undefined, [22])); +console.log(probe("a", { label: "x", nodeVersion: undefined })); + +console.log(defaults()); +console.log(defaults(undefined)); +console.log(defaults(undefined, "y")); +console.log(defaults("x", undefined, "r")); + +const available: number[] = [18, 20, 22]; +console.log(version("/work", "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: settingsFor(true)?.nodeVersion }, undefined, available)); +console.log(version("/work", undefined, undefined, undefined, available)); +console.log(inspect({ nodeVersion: undefined, list: [1, undefined, 3] })); + +// A native object typed by the island's own interface, read with `?.`. +function projectSettings(flag: boolean): Config | undefined { + return flag ? { nodeVersion: "20.x", label: "proj" } : undefined; +} +const project = projectSettings(true); +const none = projectSettings(false); +console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); +console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); +const claims = { role: "admin" }; +console.log("plain", plain({ sub: "alice" }), plain(claims)); + +async function main(): Promise { + // A package.json the program writes just before the island reads it. + const dir = mkdtempSync(join(tmpdir(), "argprobe-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ engines: { node: "22.x" } })); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); + rmSync(join(dir, "package.json")); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: "18.x" }, { isDev: true })); + try { + await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available); + } catch (e) { + console.log("rejected", (e as Error).message); + } + // Process state the program changes after Node loaded is Node's too. + const before = process.cwd(); + process.chdir(dir); + console.log("cwd", cwd() === process.cwd(), cwd() === before); + process.chdir(before); + process.env["ARGPROBE_SETTING"] = "set-natively"; + console.log("env", envValue("ARGPROBE_SETTING")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} +void main(); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts new file mode 100644 index 000000000..6ce43de4d --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -0,0 +1,24 @@ +export interface Config { + nodeVersion?: string; + label?: string; +} +export declare function probe(a?: unknown, b?: Config, c?: unknown, d?: number[]): string; +export declare function defaults(x?: string, y?: string, ...rest: string[]): string; +export declare function version( + path: string, + fallback?: string, + config?: Config, + meta?: { nodeVersion?: string }, + available?: number[], +): string; +export declare function inspect(v: unknown): string; +export declare function nodeVersionFor( + dir: string, + fallback?: string, + config?: Config, + meta?: { isDev?: boolean }, + available?: number[], +): Promise; +export declare function cwd(): string; +export declare function envValue(name: string): string | undefined; +export declare function plain(v: object): string; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js new file mode 100644 index 000000000..5cf8f9301 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -0,0 +1,78 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. Its +// functions report exactly what arrives: argument counts, explicit +// undefined, key presence of undefined-valued fields, arrays. +const { createRequire } = require("node:module"); +const util = createRequire(__filename)(["node", "util"].join(":")); + +function describeValue(v) { + if (v === undefined) return "undefined"; + if (Array.isArray(v)) return `array(${v.length}:${v.map(describeValue).join(",")})`; + if (v !== null && typeof v === "object") + return `{${Object.keys(v) + .map((k) => `${k}=${describeValue(v[k])}`) + .join(",")}}`; + return JSON.stringify(v); +} + +exports.probe = function probe(a, b, c, d) { + return [ + `len=${arguments.length}`, + `a=${describeValue(a)}`, + `b=${describeValue(b)}`, + `in=${b !== undefined && b !== null && "nodeVersion" in b}`, + `own=${b !== undefined && b !== null && Object.prototype.hasOwnProperty.call(b, "nodeVersion")}`, + `c=${describeValue(c)}`, + `d=${describeValue(d)}`, + ].join(" "); +}; + +exports.defaults = function defaults(x = "dx", y = "dy", ...rest) { + return `${x}|${y}|rest=${rest.length}|len=${arguments.length}`; +}; + +exports.version = function version(path, fallback = "unused", config = {}, meta, available) { + const wanted = config.nodeVersion ?? (meta && meta.nodeVersion) ?? null; + const list = Array.isArray(available) ? available : []; + if (wanted === null) { + const latest = Math.max(...list); + return `${path}: ${list.includes(latest) ? `${latest}.x` : fallback}`; + } + return `${path}: ${wanted}`; +}; + +exports.inspect = (v) => util.inspect(v); + +// A version-lookup helper's shape: async, defaults for omitted +// arguments, engines.node from the package.json in `dir` first. +exports.nodeVersionFor = async function nodeVersionFor( + dir, + fallback = "unused", + config = {}, + meta = {}, + available = [16], +) { + const fs = require("node:fs/promises"); + const path = require("node:path"); + let engines = null; + try { + engines = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")).engines ?? null; + } catch { + engines = null; + } + if (engines && engines.node) return `engines:${engines.node}:${Math.max(...available)}`; + const configured = config.nodeVersion || fallback; + if (configured === "unused") throw new Error(`Found invalid or discontinued Node.js Version: "${configured}"`); + return `configured:${configured}:${meta.isDev === true}`; +}; + +exports.cwd = () => process.cwd(); +exports.envValue = (name) => process.env[name]; + +// A native plain object is a plain object here (jose's isObject). +exports.plain = (v) => + [ + Object.getPrototypeOf(v) === Object.prototype, + Object.prototype.toString.call(v), + Object.getPrototypeOf(Object.getPrototypeOf(v)) === null, + ].join(" "); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json new file mode 100644 index 000000000..4b7cb717e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json @@ -0,0 +1 @@ +{ "name": "argprobe", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6829-node-island-declared-types/main.ts b/tests/corpus/6829-node-island-declared-types/main.ts new file mode 100644 index 000000000..52c2e5c40 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Typed code names an island package's declared interfaces and generic +// aliases: the package's declarations type it, while its values cross the +// boundary as handles. +import { Arr, Num, Str, check, type Static, type TArray, type TNumber, type TSchema, type TString } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const sum: Tool> = { + name: "sum", + parameters: Arr(Num()), + execute: (id, params) => `${id}: ${params.reduce((a, b) => a + b, 0)}`, +}; +const shout: Tool = { + name: "shout", + parameters: Str(), + execute: (id, params) => `${id}: ${params.toUpperCase()}`, +}; + +function run(tool: Tool, input: unknown): string { + if (!check(tool.parameters, input)) return `${tool.name}: rejected ${JSON.stringify(input)}`; + return tool.execute(tool.name, input); +} + +function describeAll(schemas: TSchema[]): string[] { + return schemas.map((s) => `${s.kind} ${s.describe()}`); +} + +console.log(run(sum, [1, 2, 3])); +console.log(run(sum, [1, "2"])); +console.log(run(shout, "quiet")); +console.log(run(shout, 7)); +console.log(describeAll([sum.parameters, shout.parameters, Arr(Arr(Str()))]).join(" | ")); +const tools: Tool[] = [sum, shout]; +console.log(tools.map((t) => t.name).join(",")); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } 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/7015-path-platform-namespace-values.ts b/tests/corpus/7015-path-platform-namespace-values.ts new file mode 100644 index 000000000..bea5c325d --- /dev/null +++ b/tests/corpus/7015-path-platform-namespace-values.ts @@ -0,0 +1,41 @@ +// node:path's platform namespaces as VALUES. `posix` and `win32` are +// members of the path module object, so a chosen-at-run-time path API +// (`const api = cond ? win32 : { basename, dirname }`) is that object on +// one arm and a plain record on the other; calls through it dispatch on +// the value, as they do in Node. The namespaces keep the module object's +// identity and member interning (`posix === path.posix`, one closure per +// function), and a function read off the module namespace is the same +// value the named import binds. +import * as path from "node:path"; +import { basename, dirname, join, normalize, posix, win32 } from "node:path"; + +// The platform-namespace choice (the portable-API idiom): win32 here, so +// the calls answer win32's rules on every host, like Node's namespace. +const useWin32 = process.platform === "win32" || "C:\\tmp\\x".includes("\\"); +const api = useWin32 ? win32 : { basename, dirname }; + +const parent = api.dirname("/tmp/pkg/node_modules/@scope/name"); +console.log(parent, api.basename(parent), api.dirname(parent)); +console.log(api.basename("/a/b/c.txt"), api.basename("C:\\a\\b\\c.txt", ".txt")); + +// The same shape through a class/closure boundary and a reassignment. +function pick(flag: boolean): typeof win32 | { basename: typeof basename; dirname: typeof dirname } { + return flag ? win32 : { basename, dirname }; +} +console.log(pick(true).basename("C:\\x\\y.txt"), pick(false).basename("/x/y.txt")); + +// A member of the module namespace read as a value is the named import's +// value, and the whole namespace compares by module identity. +type PathModule = typeof import("node:path"); +const whole = path as PathModule; +const pathJoin = whole.join; +console.log(pathJoin("a", "b"), pathJoin === path.join, typeof pathJoin); +console.log(typeof posix, typeof win32, posix === path.posix, win32 === path.win32); +console.log(win32.join("a", "c")); + +// Direct calls and values mix freely; the namespace value answers every +// lowerable member. +const chosen = useWin32 ? win32 : posix; +console.log(chosen.join("a", "b", "..", "c"), chosen.normalize("a\\\\b//c")); +console.log(chosen.extname("C:\\a\\b.tar.gz"), chosen.isAbsolute("C:\\a"), chosen.sep); +console.log(join("x", "y"), normalize("a//b/../c")); diff --git a/tests/corpus/7016-union-field-delete.ts b/tests/corpus/7016-union-field-delete.ts new file mode 100644 index 000000000..58be84011 --- /dev/null +++ b/tests/corpus/7016-union-field-delete.ts @@ -0,0 +1,51 @@ +// `delete u.f` on a union receiver: every arm declares `f` as an OPTIONAL +// field, so each arm's slot can hold its ABSENT state and the delete +// removes the own property on whichever arm the value has — `in` and +// JSON.stringify agree with Node, and the mutation reaches the caller's +// object rather than a projection of it. A required field (a slot with no +// absence to represent) still refuses at compile time. +interface A { + type: "a"; + content: string; + details?: number; +} +interface B { + type: "b"; + content: string; + details?: string; +} +type Either = A | B; + +function strip(e: Either): Either { + delete e.details; + return e; +} + +const a: Either = { type: "a", content: "x", details: 1 }; +const b: Either = { type: "b", content: "y", details: "d" }; +console.log("before", "details" in a, "details" in b, JSON.stringify(a), JSON.stringify(b)); + +const outA = strip(a); +const outB = strip(b); +console.log("details" in outA, "details" in outB, JSON.stringify(outA), JSON.stringify(outB)); +console.log(outA.details, outB.details); + +// The delete mutates the value the caller passed (no copy): the alias that +// still holds the arm's own type sees the removal too. +console.log("alias", "details" in a, a.details); + +// Deleting an absent field is a no-op, and a later write makes the field +// present again. +const c: A = { type: "a", content: "z" }; +delete c.details; +console.log("absent", "details" in c, JSON.stringify(c), c.details); +c.details = 7; +console.log("present", "details" in c, JSON.stringify(c)); + +// Dispatch through a value produced at run time, both arms of the union. +const pick = (flag: boolean): Either => + flag ? strip({ type: "a", content: "p" }) : strip({ type: "b", content: "q", details: "r" }); +for (const value of [pick(true), pick(false)]) { + delete value.details; + console.log(value.type, "details" in value, JSON.stringify(value), value.content); +} diff --git a/tests/corpus/7017-nonnullable-union.ts b/tests/corpus/7017-nonnullable-union.ts new file mode 100644 index 000000000..728a19009 --- /dev/null +++ b/tests/corpus/7017-nonnullable-union.ts @@ -0,0 +1,74 @@ +// `NonNullable` over a union: the type mapper maps the checker's answer +// directly — the union with its null/undefined arms removed — so the result +// is an ordinary union VALUE (tagged, narrowed, serialized like any other), +// not a checked-dynamic slot. `Compat` carries no unit arm, so +// `NonNullable` is `Compat` itself; `Compat | undefined` loses the +// unit arm. The runtime values are Node's. +interface OpenAiCompat { + kind: "openai"; + maxTokensField?: string; + supportsStore?: boolean; +} + +interface AnthropicCompat { + kind: "anthropic"; + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +function takePrime(value: NonNullable): string { + return value; +} + +function takeCompat(value: NonNullable): Compat { + return value; +} + +function takeWhole(value: NonNullable): Compat { + return value; +} + +function takeNested(value: NonNullable | undefined>): Compat { + return value; +} + +// A generic `NonNullable` resolves per instantiation the same way. +function takeGeneric(value: T | undefined): NonNullable { + return value as NonNullable; +} + +function pick(flag: boolean): Compat { + return flag ? { kind: "openai", supportsStore: true } : { kind: "anthropic" }; +} + +const asserted = pick(true) as NonNullable; +const whole: NonNullable = pick(false); +const generic = takeGeneric({ kind: "openai", supportsStore: false }); + +console.log( + takePrime("p"), + JSON.stringify(takeCompat(pick(true))), + JSON.stringify(takeWhole({ kind: "openai", supportsStore: true })), + JSON.stringify(takeNested(pick(false))), + JSON.stringify(asserted), + JSON.stringify(whole), + JSON.stringify(generic), +); + +// The mapped type narrows like any union: each arm reads its own fields. +function describe(value: NonNullable): string { + if (value.kind === "openai") return `openai ${value.supportsStore} ${value.maxTokensField}`; + return `anthropic ${value.supportsLongCacheRetention}`; +} + +console.log( + describe({ kind: "openai", supportsStore: true }), + describe({ kind: "openai", maxTokensField: "max_completion_tokens" }), + describe({ kind: "anthropic" }), + describe({ kind: "anthropic", supportsLongCacheRetention: false }), +); + +// An array of the mapped type is an ordinary array of the union value. +const all: NonNullable[] = [takeCompat(pick(true)), pick(false)]; +for (const entry of all) console.log(describe(entry)); diff --git a/tests/corpus/7018-bigint-stats.ts b/tests/corpus/7018-bigint-stats.ts new file mode 100644 index 000000000..2d69626b3 --- /dev/null +++ b/tests/corpus/7018-bigint-stats.ts @@ -0,0 +1,122 @@ +// BigIntStats: `statSync(path, { bigint: true })` and its siblings answer the +// same stat snapshot under Node's bigint member table. Every number member of +// Stats — the integers, the *Ms millisecond members and the BigIntStats-only +// *Ns nanosecond members — reads as a bigint; only the four time members stay +// Dates (their getTime() is a number). The literal option selects the table, +// and throwIfNoEntry: false keeps the BigIntStats | undefined union. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const posix = process.platform !== "win32"; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bigstats-")); +process.chdir(dir); +const stamp = 1700000100; // a fixed time: whole seconds, zero nanoseconds +fs.writeFileSync("data.txt", "0123456789"); +fs.utimesSync("data.txt", stamp, stamp); + +const st = fs.statSync("data.txt", { bigint: true }); + +console.log(typeof st.size, st.size, st.size === 10n); +console.log( + typeof st.dev, + typeof st.ino, + typeof st.mode, + typeof st.nlink, + typeof st.uid, + typeof st.gid, + typeof st.rdev, + typeof st.blksize, + typeof st.blocks, +); +console.log( + st.isFile(), + st.isDirectory(), + st.isSymbolicLink(), + st.isBlockDevice(), + st.isCharacterDevice(), + st.isFIFO(), + st.isSocket(), +); +console.log(typeof st.atimeMs, typeof st.mtimeNs, st.mtimeMs, st.mtimeNs); +console.log(st.mtimeNs === 1700000100000000000n, st.mtimeMs === 1700000100000n); +console.log(st.mtime instanceof Date, st.mtime.getTime() === 1700000100000); +console.log(st.mtimeMs === st.mtimeNs / 1000000n); +console.log(String(st.size), `${st.size}`, st.size + 1n, st.size * 2n, st.size.toString()); + +// The option literal picks the representation: omitting it is Stats. +console.log(typeof fs.statSync("data.txt").size, st.mode > 0n); +console.log(typeof fs.statSync("data.txt", { bigint: false }).size, st.mtimeNs > 0n); + +// lstatSync answers the link's own snapshot, still bigint-typed. +if (posix) { + fs.symlinkSync("data.txt", "link.txt"); + const link = fs.lstatSync("link.txt", { bigint: true }); + console.log(typeof link.size, link.size === 8n, link.isSymbolicLink(), link.isFile()); + console.log(typeof fs.lstatSync("data.txt", { bigint: true }).mtimeNs); +} + +// fstatSync over an open descriptor reads the same snapshot. +const fd = fs.openSync("data.txt", "r"); +const viaFd = fs.fstatSync(fd, { bigint: true }); +console.log( + typeof viaFd.size, + viaFd.size, + viaFd.ino === st.ino, + viaFd.mode === st.mode, + viaFd.mtimeNs === st.mtimeNs, +); +fs.closeSync(fd); + +// throwIfNoEntry: false keeps the union: a missing entry answers undefined. +const missing = fs.statSync("missing.txt", { bigint: true, throwIfNoEntry: false }); +console.log(missing === undefined); +const present = fs.statSync("data.txt", { bigint: true, throwIfNoEntry: false }); +if (present !== undefined) console.log(present.size, typeof present.ino, present.isFile()); + +// The Date members agree with the nanosecond members. +console.log(st.atime.getTime() === Number(st.atimeNs / 1000000n)); +console.log(st.ctime instanceof Date, typeof st.ctimeMs, typeof st.ctimeNs); + +// A fractional millisecond of at least 0.5: Stats rounds its Date up to the +// next millisecond, while a BigIntStats Date is its truncated *Ms member. +const fractional = stamp + 0.0007; +fs.utimesSync("data.txt", fractional, fractional); +const rounded = fs.statSync("data.txt"); +const truncated = fs.statSync("data.txt", { bigint: true }); +console.log(rounded.mtime.getTime(), truncated.mtime.getTime(), truncated.mtimeMs); +console.log(rounded.atime.getTime() - truncated.atime.getTime()); +console.log( + truncated.atime.getTime() === Number(truncated.atimeMs), + truncated.mtime.getTime() === Number(truncated.mtimeMs), + truncated.ctime.getTime() === Number(truncated.ctimeMs), + truncated.birthtime.getTime() === Number(truncated.birthtimeMs), +); + +// The bigint option is evaluated like any option member: its effects run, +// in member order, before the stat, and an exception it throws is the call's. +let evaluated = ""; +function note(step: string): true { + evaluated += step; + return true; +} +function refuse(): true { + throw new Error("refused"); +} +const counted = fs.statSync("data.txt", { bigint: note("s") }); +const linked = fs.lstatSync("data.txt", { bigint: note("l") }); +const byFd = fs.openSync("data.txt", "r"); +const described = fs.fstatSync(byFd, { bigint: note("f") }); +fs.closeSync(byFd); +console.log(evaluated, typeof counted.size, typeof linked.size, typeof described.size); +evaluated = ""; +const probed = fs.statSync("missing.txt", { + bigint: note("b"), + throwIfNoEntry: ((evaluated += "t"), false), +}); +console.log(evaluated, probed === undefined); +try { + fs.statSync("data.txt", { bigint: refuse() }); +} catch (error) { + console.log((error as Error).message); +} diff --git a/tests/corpus/8423-accessor-member-kind-mismatch.ts b/tests/corpus/8423-accessor-member-kind-mismatch.ts new file mode 100644 index 000000000..4cdc3136c --- /dev/null +++ b/tests/corpus/8423-accessor-member-kind-mismatch.ts @@ -0,0 +1,93 @@ +// An object literal whose get/set accessor satisfies a declared DATA member +// keeps the accessor live through the declared type, as in Node: every read +// calls the getter, a write calls the setter, Object.keys lists the accessor +// name in declaration order, and JSON.stringify calls the getter. The +// mirrored direction (a data property satisfying a declared accessor member) +// keeps the declared accessor's identity too. + +interface Counter { + label: string; + size: number; +} + +// A recomputed getter and a setter that both observe later state. +function makeCounter(label: string): Counter { + let value = 1; + return { + get size(): number { + console.log("get size"); + return value; + }, + set size(next: number) { + console.log("set size", next); + value = next * 2; + }, + label, + }; +} + +// The value passes onward to other typed slots of the declared type. +function readTwice(counter: Counter): number { + return counter.size + counter.size; +} + +function bump(counter: Counter): void { + counter.size = counter.size + 1; +} + +const counter = makeCounter("counter"); +console.log("read", counter.size, counter.label); +bump(counter); +console.log("stored", counter.size); +console.log("keys", Object.keys(counter).join(",")); +console.log("json", JSON.stringify(counter)); +console.log("twice", readTwice(counter)); + +// A getter that throws until the object observes later state. +interface Gate { + value: number; +} + +interface GateHost { + gate: Gate; + open: () => void; +} + +function makeGate(): GateHost { + let open = false; + const gate: Gate = { + get value(): number { + if (!open) throw new Error("inactive"); + return 7; + }, + }; + return { gate, open: () => (open = true) }; +} + +const host = makeGate(); +try { + console.log(host.gate.value); +} catch (error) { + console.log("threw", error instanceof Error ? error.message : "?"); +} +host.open(); +console.log("open", host.gate.value, host.gate.value); + +// The mirrored direction: a data literal satisfying a declared accessor. +interface Live { + readonly label: string; + get size(): number; +} + +function readLive(live: Live): string { + return live.label + ":" + live.size; +} + +function decorate(label: string, size: number): Live { + return { label, size }; +} + +const live = decorate("plain", 5); +console.log("live", readLive(live)); +console.log("live-keys", Object.keys(live).join(",")); +console.log("live-json", JSON.stringify(live)); diff --git a/tests/corpus/8516-empty-pattern-requires-object-coercible.js b/tests/corpus/8516-empty-pattern-requires-object-coercible.js new file mode 100644 index 000000000..069ec2f83 --- /dev/null +++ b/tests/corpus/8516-empty-pattern-requires-object-coercible.js @@ -0,0 +1,96 @@ +// An empty destructuring pattern still performs its one observable step: +// RequireObjectCoercible for `{}` and GetIterator for `[]`. A checked-dynamic +// source reaches that step at runtime, so null and undefined throw the same +// TypeError Node does while every other value — including primitives, which +// ToObject wraps — is accepted. The throws are printed through +// `instanceof` rather than the message, since V8 spells the source +// differently per position (an argument is "object null", a declaration +// names its expression). + +function report(label, fn) { + try { + fn(); + console.log(label, "no-throw"); + } catch (error) { + console.log(label, error instanceof TypeError ? "TypeError" : "other"); + } +} + +function acceptsWhatever(value) { + return value; +} + +report("object-null", () => { + const source = acceptsWhatever(null); + const {} = source; +}); +report("object-undefined", () => { + const source = acceptsWhatever(undefined); + const {} = source; +}); + +function objectParameter({}) { + return "bound"; +} +report("param-null", () => { + objectParameter(null); +}); +report("param-undefined", () => { + objectParameter(undefined); +}); +report("param-number", () => { + console.log("param-number returned", objectParameter(7)); +}); +report("param-string", () => { + console.log("param-string returned", objectParameter("text")); +}); + +function arrayParameter([]) { + return "bound"; +} +report("array-param-null", () => { + arrayParameter(null); +}); +report("array-param-undefined", () => { + arrayParameter(undefined); +}); + +function nestedObject({ inner: {} }) { + return "nested"; +} +report("nested-null", () => { + nestedObject({ inner: null }); +}); +report("nested-undefined", () => { + nestedObject({ inner: undefined }); +}); +report("nested-object", () => { + console.log("nested-object returned", nestedObject({ inner: {} })); +}); + +// A loop head rebinds the pattern per iteration, exactly like a declaration. +report("for-of-object", () => { + for (const {} of acceptsWhatever([null])) { + console.log("never"); + } +}); + +// Non-nullish primitives are accepted by both pattern kinds. +report("primitives", () => { + const numbers = [0, "", false, 1, "x"]; + for (const value of numbers) { + const {} = value; + console.log("destructured", JSON.stringify(value), typeof value); + } +}); + +// The assignment form keeps its own (already exact) check. +report("assignment-null", () => { + let bound; + ({} = bound); +}); +report("assignment-number", () => { + let bound = 5; + ({} = bound); + console.log("assignment-number finished"); +}); diff --git a/tests/corpus/990-process-basics.ts b/tests/corpus/990-process-basics.ts index c09e19b74..c902d0c78 100644 --- a/tests/corpus/990-process-basics.ts +++ b/tests/corpus/990-process-basics.ts @@ -31,3 +31,10 @@ console.log(nonEmpty === process.argv.length); const cwd = process.cwd(); console.log(cwd); console.log(cwd === process.cwd(), cwd.startsWith("/")); + +// A stdlib-global snapshot (`const proc = globalThis.process`) lowers to +// alias plumbing — no storage, no code — and reads through it resolve to +// the global itself. +const proc = globalThis.process; +console.log(proc.pid === process.pid, proc.platform === process.platform); +console.log(proc.argv === process.argv); 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/dyn-generator-method-value.js b/tests/corpus/dyn-generator-method-value.js new file mode 100644 index 000000000..f1565aab0 --- /dev/null +++ b/tests/corpus/dyn-generator-method-value.js @@ -0,0 +1,53 @@ +/* A generator method VALUE crossing the checked-value boundary. + * + * A class whose methods are reachable through its prototype object answers + * its methods as checked (dynamic) values: the value read off an instance is + * the same function object the prototype carries, a checked callback can hold + * it and call it, and the generator object it produces keeps the generator + * protocol across the box. Node is the oracle. */ +class Seq { + constructor(tag) { + this.tag = tag; + } + *items(n) { + for (let i = 0; i < n; i++) yield this.tag + ":" + i; + return n; + } + async *later(n) { + yield n + 1; + return n; + } +} + +function sameValue(actual, expected, label) { + if (actual !== expected) throw new Error("not the same: " + label); +} +function typeofOf(value) { + return typeof value; +} +function callWith(receiver, fn, ...args) { + return fn.apply(receiver, args); +} + +const seq = new Seq("a"); +sameValue(seq.items, Seq.prototype.items, "instance method value"); +sameValue(seq.items, seq.items, "repeated read"); +sameValue(seq.later, Seq.prototype.later, "async instance method value"); +console.log(typeofOf(seq.items), typeofOf(seq.later)); + +const gen = callWith(seq, seq.items, 2); +console.log(gen.next().value, gen.next().value); +const finished = gen.next(); +console.log(finished.value, finished.done); + +// The direct call answers its own generator object. (Identity and reflection +// on a generator object are separate, still-fenced operations.) +const direct = seq.items(1); +console.log(direct.next().value); + +// An async generator value crosses the same boundary. +callWith(seq, seq.later, 4) + .next() + .then(function (step) { + console.log("async", step.value, step.done); + }); diff --git a/tests/corpus/erased-never-member-read.ts b/tests/corpus/erased-never-member-read.ts new file mode 100644 index 000000000..c8d19edb5 --- /dev/null +++ b/tests/corpus/erased-never-member-read.ts @@ -0,0 +1,54 @@ +// An `any`-member read off a receiver the checker narrowed to `never` (the +// default branch of a switch that exhausts every arm): `const _x: never = +// event` proves the branch unreachable to the checker, and the `as any` +// erasure that escapes it hides the union the value still holds. Node reads +// the property off the object it is, so the read is the union's shared +// field. + +type Event = { type: "start"; at: number } | { type: "stop"; at: number; reason: string }; + +function describe(event: Event): string { + switch (event.type) { + case "start": + return `start ${event.at}`; + case "stop": + return `stop ${event.at} ${event.reason}`; + default: { + const unreachable: never = event; + void unreachable; + return `unhandled ${(event as any).type as string}`; + } + } +} + +console.log(describe({ type: "start", at: 1 })); +console.log(describe({ type: "stop", at: 2, reason: "done" })); + +// The same read under an `unknown` erasure and a `!` assertion, and through +// a helper function (the shape a warning reporter takes). +function unhandled(event: Event): string { + switch (event.type) { + case "start": + return "s"; + case "stop": + return "t"; + default: + return `unknown event ${(event as unknown as { type: string }).type}`; + } +} +console.log(unhandled({ type: "start", at: 1 }), unhandled({ type: "stop", at: 2, reason: "r" })); + +// The same read through an `as any` helper that names the field explicitly +// and through the value's OTHER shared field, so the union's layout is not +// the reason either read succeeds. +function kind(event: Event): string { + switch (event.type) { + case "start": + return "first"; + case "stop": + return "second"; + default: + return `kind:${(event as any).type}`; + } +} +console.log(kind({ type: "start", at: 7 }), kind({ type: "stop", at: 8, reason: "x" })); 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/frozen-checked-records-sloppy.cjs b/tests/corpus/frozen-checked-records-sloppy.cjs new file mode 100644 index 000000000..f6edab231 --- /dev/null +++ b/tests/corpus/frozen-checked-records-sloppy.cjs @@ -0,0 +1,37 @@ +// Untyped values frozen in sloppy-mode code and then held as typed records +// stay frozen: writes through typed parameters and a typed `this` leave them +// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value +// checked into the same record type stays writable. + +/** @typedef {{ count: number, label: string }} Counter */ + +/** @param {Counter} c @param {number} by */ +function bump(c, by) { + c.count += by; + c.label = c.label + "!"; + return c.count; +} + +/** @this {Counter} @param {number} by */ +function bumpThis(by) { + this.count += by; + this.label = "this"; + return this.count; +} + +/** @type {Counter} */ +const literal = Object.freeze({ count: 1, label: "a" }); +console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal)); + +/** @type {Counter} */ +const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}')); +console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed)); + +/** @type {Counter} */ +const open = JSON.parse('{"count":100,"label":"c"}'); +console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open)); + +/** @type {Counter[]} */ +const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }]; +for (const item of list) bump(item, 1); +console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1])); diff --git a/tests/corpus/frozen-checked-records.ts b/tests/corpus/frozen-checked-records.ts new file mode 100644 index 000000000..1c205079e --- /dev/null +++ b/tests/corpus/frozen-checked-records.ts @@ -0,0 +1,29 @@ +// An untyped value frozen and then held as a typed record stays frozen: +// writes to it in strict mode code throw Node's TypeError and leave it +// unchanged. An unfrozen value checked into the same record type stays +// writable. + +type Counter = { count: number; label: string }; + +function bump(c: Counter, by: number): string { + try { + c.count += by; + return "wrote " + c.count; + } catch (e) { + return e instanceof TypeError ? "TypeError" : "other"; + } +} + +const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter; +console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen)); + +const open = JSON.parse('{"count":10,"label":"b"}') as Counter; +console.log(bump(open, 2), open.count, Object.isFrozen(open)); + +const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); +const viaUnknown = raw as Counter; +console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); + +// A proxy checked into the record type stays writable. +const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter; +console.log(bump(proxied, 2), proxied.count); diff --git a/tests/corpus/function-name-const-alias.mjs b/tests/corpus/function-name-const-alias.mjs new file mode 100644 index 000000000..a534c50ec --- /dev/null +++ b/tests/corpus/function-name-const-alias.mjs @@ -0,0 +1,20 @@ +// The name of a function value read through a const binding: a builtin +// keeps Node's name (including when destructured), an anonymous function or +// arrow takes the binding's name through parentheses, and an alias of a +// named function keeps the function's name. +import * as path from "node:path"; +import * as processModule from "node:process"; + +const { cwd } = process; +const { cwd: renamed } = processModule; +const join = path.join; +const parenthesized = (function () {}); +const arrow = (() => 0); +const nested = ((function () {})); +function named() {} +const alias = named; +const aliasOfAlias = alias; +console.log(cwd.name, renamed.name, join.name); +console.log(parenthesized.name, arrow.name, nested.name); +console.log(alias.name, aliasOfAlias.name); +console.log(cwd === process.cwd, typeof cwd()); 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/optional-param-function-values.ts b/tests/corpus/optional-param-function-values.ts new file mode 100644 index 000000000..716c07409 --- /dev/null +++ b/tests/corpus/optional-param-function-values.ts @@ -0,0 +1,107 @@ +// Functions whose own parameters are optional, defaulted or extra take part +// as VALUES in slots that spell the completed signature (WP-A, the +// `tool-definition-wrapper` shape): a method's narrower required parameter +// against the declaring interface's optional one, a handler with more +// (trailing optional) parameters than the slot's type names, and a nested +// function declaration returned in an object literal. Node semantics: the +// slot passes fewer arguments than the value declares, so the missing ones +// are `undefined` and defaults apply. + +interface Ctx { + cwd: string; +} + +interface ToolCtx extends Ctx { + tools: string[]; +} + +interface RequiredSlot { + run(id: string, ctx: ToolCtx): string; +} + +// A: a method declaring its parameter OPTIONAL fills a slot that requires +// the (narrower) parameter: the slot always passes it, so the `undefined` +// arm is unreachable through that slot. +const a: RequiredSlot = { + run(id: string, ctx?: Ctx): string { + return `${id}@${ctx === undefined ? "-" : ctx.cwd}`; + }, +}; +console.log(a.run("A", { cwd: "/a", tools: [] })); + +// The same method through its own object: the omitted call is real here. +const aLiteral = { + run(id: string, ctx?: Ctx): string { + return `${id}@${ctx === undefined ? "-" : ctx.cwd}`; + }, +}; +console.log(aLiteral.run("A"), aLiteral.run("A", { cwd: "/a" })); + +// B: a handler with an EXTRA trailing optional parameter in a two-parameter +// slot: the slot never passes it, so it reads undefined. +type Handler = (id: string, payload: number) => string; +const handlers: Record = { + plain(id, payload, extra?: string) { + return `${id}:${payload}:${extra === undefined ? "-" : extra}`; + }, +}; +console.log(handlers["plain"]("one", 1)); + +const annotated: Handler = (id, payload, extra?: string) => + `${id}:${payload}:${extra === undefined ? "-" : extra}`; +console.log(annotated("two", 2)); + +// A defaulted trailing parameter: the slot's omission applies the default. +const withDefault: Handler = (id, payload, extra = "dflt") => `${id}:${payload}:${extra}`; +console.log(withDefault("three", 3)); + +// C: a nested function DECLARATION (overloaded, with an optional parameter) +// returned as a value: lowering the declaration builds its body; the value +// use at the return re-checks the completed signature against the slot. +interface Writer { + (text: string): string; +} +function makeWriter(prefix: string): Writer { + function write(text: string): string; + function write(text: string, times?: number): string; + function write(text: string, times?: number): string { + const n = times === undefined ? 1 : times; + let out = ""; + for (let i = 0; i < n; i++) out += `${prefix}${text}`; + return out; + } + return write; +} +const writer = makeWriter("> "); +console.log(writer("x")); + +// Control: the same signatures through direct calls. +function greet(name?: string): string { + return name === undefined ? "hello" : `hello ${name}`; +} +console.log(greet(), greet("ada")); +function repeat(text: string, times = 3, sep = "|"): string { + let out = ""; + for (let i = 0; i < times; i++) out += (i === 0 ? "" : sep) + text; + return out; +} +console.log(repeat("z"), repeat("z", 2), repeat("z", 2, "+")); +const pick: (x: number | undefined) => number = (x?: number) => (x === undefined ? -1 : x); +console.log(pick(undefined), pick(4)); + +// A method whose optional parameter is READ through the value it was given +// (the `arguments.length`-free shape the tool registry uses). +interface UnsafeSlot { + call(payload: string): string; +} +const unsafe: UnsafeSlot = { + call(payload: string, options?: { tag: string }): string { + return `${payload}/${options === undefined ? "-" : options.tag}`; + }, +}; +console.log(unsafe.call("q")); + +// A value stored in a union-typed slot (the omitted call stays observable). +type Runner = RequiredSlot | undefined; +const maybeRunner: Runner = a; +console.log(maybeRunner === undefined ? "none" : maybeRunner.run("U", { cwd: "/u", tools: [] })); diff --git a/tests/corpus/path-namespace-spreads.js b/tests/corpus/path-namespace-spreads.js new file mode 100644 index 000000000..c9931a451 --- /dev/null +++ b/tests/corpus/path-namespace-spreads.js @@ -0,0 +1,23 @@ +// Object spreads of node:path's namespaces copy the exports. The ES module +// namespace has its keys sorted and also `default`, the module object; +// posix, win32 and the default import are module objects, in Node's member +// order. The copies hold the module's own functions. The key lists leave out +// format and parse, which have no function values yet. +import * as path from "node:path"; +import defaultPath, { posix, win32 } from "node:path"; + +const fromNamespace = { ...path }; +const fromDefault = { ...defaultPath }; +const fromPosix = { ...posix }; +const fromWin32 = { ...win32 }; +const keys = (copy) => JSON.stringify(Object.keys(copy).filter((key) => key !== "format" && key !== "parse")); +console.log(keys(fromNamespace)); +console.log(keys(fromPosix)); +console.log(keys(fromDefault) === keys(fromPosix)); +console.log(keys(fromWin32) === keys(fromPosix)); +console.log(fromNamespace.join === path.join, fromNamespace.default === posix, fromPosix.basename === posix.basename); +console.log(fromWin32.join === win32.join, fromWin32.posix === posix, fromPosix.win32 === win32); +console.log(fromWin32._makeLong === win32.toNamespacedPath, fromDefault.sep, fromWin32.sep); +console.log(fromWin32.join("a", "b"), fromNamespace.normalize("/a//b/../c")); +const merged = { first: 0, ...win32, extra: 1 }; +console.log(Object.keys(merged)[0], merged.sep, merged.extra, keys(merged)); diff --git a/tests/corpus/process-cwd-value.cjs b/tests/corpus/process-cwd-value.cjs new file mode 100644 index 000000000..6a6d616c6 --- /dev/null +++ b/tests/corpus/process-cwd-value.cjs @@ -0,0 +1,7 @@ +// process.cwd stored as a value from CommonJS: the same function as the +// process module's member, read when it is called. +const processModule = require("node:process"); +const get = process.cwd; +console.log(get === processModule.cwd, get.name, get() === process.cwd()); +const { cwd } = processModule; +console.log(cwd === get, typeof cwd()); diff --git a/tests/corpus/process-cwd-value.mjs b/tests/corpus/process-cwd-value.mjs new file mode 100644 index 000000000..016b914e6 --- /dev/null +++ b/tests/corpus/process-cwd-value.mjs @@ -0,0 +1,22 @@ +// process.cwd stored as a value: one identity across the global, named, +// namespace and default forms, Node's name and length, and the directory +// read when it is called. +import { cwd } from "node:process"; +import * as processModule from "node:process"; +import processDefault from "node:process"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const get = process.cwd; +console.log(typeof get, get.name, get.length); +console.log(get === cwd, get === processModule.cwd, get === processDefault.cwd); +console.log(get() === process.cwd(), cwd() === process.cwd()); + +const start = get(); +const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-cwd-value-"))); +process.chdir(dir); +console.log(get() === dir, cwd() === dir, [0].map(() => get())[0] === dir); +process.chdir(start); +console.log(get() === start); +rmSync(dir, { recursive: true }); 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/corpus/sloppy-this-global-computed.cjs b/tests/corpus/sloppy-this-global-computed.cjs new file mode 100644 index 000000000..ac526e086 --- /dev/null +++ b/tests/corpus/sloppy-this-global-computed.cjs @@ -0,0 +1,26 @@ +// A sloppy-mode function called with undefined or null as its receiver reads +// builtin globals on the global object by computed keys, directly and through +// a variable its `this` initializes. + +function readByKey(key) { + return this[key]; +} +function aliasedRead(key) { + var root = this; + return root[key]; +} + +console.log(readByKey("Array") === Array, readByKey("JSON") === JSON); +console.log(aliasedRead("Map") === Map, typeof aliasedRead("setTimeout")); +console.log(readByKey.call(null, "Promise") === Promise, readByKey.call({ Promise: 1 }, "Promise")); +console.log(readByKey("globalThis") === globalThis, readByKey("missingGlobal")); + +const names = ["Object", "Symbol", "Uint8Array"]; +const values = [Object, Symbol, Uint8Array]; +console.log(names.map((name, i) => readByKey(name) === values[i])); + +function strictRead(key) { + "use strict"; + return this === undefined ? "no receiver" : this[key]; +} +console.log(strictRead("Array"), strictRead.call({ Array: 2 }, "Array")); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs new file mode 100644 index 000000000..ca7f212db --- /dev/null +++ b/tests/corpus/sloppy-this-global.cjs @@ -0,0 +1,69 @@ +// In sloppy-mode functions, `this` is the global object when the function is +// called with undefined or null as its receiver: by a plain call, by a +// detached method, and through call and apply. Strict functions and arrows +// keep the receiver they get. + +// Before anything else reads the global object. +function readConsole() { + return typeof this.console.log === "function" && this.console === console; +} +console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); + +// Builtins read through the global `this`, directly and through a variable. +function readBuiltins() { + return [this.Array === Array, this.JSON === JSON, typeof this.setTimeout]; +} +function aliasedRoot() { + var root = this; + return root.Object === Object && typeof root.Promise; +} +console.log(readBuiltins(), aliasedRoot()); + +// Builtins read through the global `this` by literal keys. +function readByLiteral() { + var root = this; + return [this["Map"] === Map, root["Math"] === Math, typeof this[`Set`], this[0]]; +} +console.log(readByLiteral(), readByLiteral.call({ Map: 1 })[0]); + +function receiver() { + return this; +} +function kind() { + return typeof this; +} +function strictReceiver() { + "use strict"; + return this; +} + +console.log(receiver() === globalThis, kind()); +console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis); +console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis); +console.log(strictReceiver() === undefined, strictReceiver.call(null) === null); + +const holder = { + name: "holder", + self() { + return this; + }, +}; +const detached = holder.self; +console.log(holder.self() === holder, detached() === globalThis); + +function outer() { + function inner() { + return this; + } + return inner() === globalThis; +} +console.log(outer(), outer.call({ name: "o" })); + +globalThis.sloppyMarker = 42; +function readMarker() { + return this.sloppyMarker; +} +console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 })); + +const arrow = () => this; +console.log(arrow() === module.exports, arrow.call(null) === module.exports); diff --git a/tests/corpus/tty-isatty-value-typed.ts b/tests/corpus/tty-isatty-value-typed.ts new file mode 100644 index 000000000..7ec7bfb31 --- /dev/null +++ b/tests/corpus/tty-isatty-value-typed.ts @@ -0,0 +1,10 @@ +// A typed alias of tty.isatty works as a callback with its declared +// signature. +import * as tty from "node:tty"; + +const isTerminal: (fd: number) => boolean = tty.isatty; +function describe(fds: number[], test: (fd: number) => boolean): string[] { + return fds.map((fd) => `${fd}:${test(fd)}`); +} +console.log(describe([-1, 0.5, 2 ** 40], isTerminal).join(" ")); +console.log(isTerminal === tty.isatty, [-1].map(isTerminal)); diff --git a/tests/corpus/tty-isatty-value.mjs b/tests/corpus/tty-isatty-value.mjs new file mode 100644 index 000000000..56aab317e --- /dev/null +++ b/tests/corpus/tty-isatty-value.mjs @@ -0,0 +1,21 @@ +// tty.isatty stored as a value keeps the direct predicate's answers, its +// identity across import forms, and its name and length. Values that are +// not integral file descriptors answer false without being coerced. +import * as tty from "node:tty"; +import { isatty } from "node:tty"; +import ttyDefault from "node:tty"; + +const probe = tty.isatty; +console.log(typeof probe, probe.name, probe.length); +console.log(probe === isatty, probe === ttyDefault.isatty); +console.log(probe(-1), probe(1.5), probe(2 ** 31), probe(Number.NaN)); +console.log([-1, 0.5].map(probe)); +// Called through an untyped callback, values that are not numbers answer +// false without being coerced. +function forward(predicate, value) { + return predicate(value); +} +let reads = 0; +const object = { valueOf() { reads++; return 1; } }; +console.log(forward(probe, undefined), forward(probe, "1"), forward(probe, 1n), forward(probe, null), forward(probe, object), reads); +console.log(forward(isatty, -1), forward(tty.isatty, 2 ** 31)); diff --git a/tests/diagnostics/bigint-fences.ts b/tests/diagnostics/bigint-fences.ts index 0b8bcb4f2..0fd668cb3 100644 --- a/tests/diagnostics/bigint-fences.ts +++ b/tests/diagnostics/bigint-fences.ts @@ -9,6 +9,9 @@ console.log(JSON.stringify(1n)); import { statSync } from "node:fs"; const stats = statSync(".", { bigint: true }); +// A BigIntStats member is a call, not a value: reading one as a value still +// has no lowering. +const read = stats.isFile; console.log(stats.size); void typed; diff --git a/tests/diagnostics/function-forms.ts b/tests/diagnostics/function-forms.ts deleted file mode 100644 index 55d717414..000000000 --- a/tests/diagnostics/function-forms.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Overload signatures compile now (they lower to nothing; calls flow -// through the implementation's ABI — corpus 1850/1851); kept here as -// working context for the fences below. -function overloaded(a: number): number; -function overloaded(a: string): string; -function overloaded(a: number | string): number | string { - return a; -} -// Type-parameter defaults compile now (bindings fill from the mapped -// default — corpus 1950); kept as working context for the fences below. -function defaulted_tp(x: T): T { - return x; -} -console.log(defaulted_tp(1)); -// Optional/default/rest parameters compile for direct calls. Typed rest -// functions are first-class values too; the remaining fence below is for a -// nested defaulted declaration with no contextual completed signature. -function optional(a?: number): number { - return a === undefined ? 0 : a; -} -function defaulted(a: number = 1): number { - return a; -} -function rest(...args: number[]): number { - return args.length; -} -// Optional/defaulted functions as values compile when the inferred binding -// spells the completed `T | undefined` signature (corpus 1535); typed rest -// signatures carry a packed-array ABI (corpus 2917). -const restRef = rest; -// Union-typed parameter defaults compile now (corpus 1535); kept here as -// working context for the fences below. -function unionDefault(x: string | null = "hi"): void { - console.log(x === null); -} -// Nested function declarations are values (const name = lambda) — fenced. -function outer(): number { - function inner(n = 2): number { - return n; - } - return inner(1) + inner(); -} -// Spread into a REST parameter lowers (the pack copies the spread's -// elements); the object and array spread shapes are covered by the -// differential corpus. -const pair = [1, 2]; -console.log(rest(...pair)); -unionDefault(); -console.log(optional(1), defaulted(), rest(1, 2), outer()); - -// Reached: collection defers its diagnostics until a reference makes -// them relevant; these references are what makes them count. -overloaded(1); -defaulted_tp(1); diff --git a/tests/diagnostics/stdlib-snapshot-value-use.ts b/tests/diagnostics/stdlib-snapshot-value-use.ts new file mode 100644 index 000000000..563dd88d8 --- /dev/null +++ b/tests/diagnostics/stdlib-snapshot-value-use.ts @@ -0,0 +1,10 @@ +// The snapshot's declaration emits no storage: only reads that resolve +// THROUGH the global (members, calls) can exist. A read that needs the +// value reports the declaration's cause — the binding, the global it +// snapshots and why neither holds a value — and the use site carries the +// inherited-blocker cascade instead of "a binding form with no lowering". +function label(): unknown { + const proc = globalThis.process; + return proc; +} +console.log(label()); diff --git a/tests/diagnostics/stdlib.ts b/tests/diagnostics/stdlib.ts index d88b5da2b..e8565c38d 100644 --- a/tests/diagnostics/stdlib.ts +++ b/tests/diagnostics/stdlib.ts @@ -6,7 +6,7 @@ import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; const read = readFileSync; console.log(read); -const cwd = process.cwd; +const exit = process.exit; const p = process; const env = process.env; const flo = Math.floor; // fixed-arity Math functions have native value forms diff --git a/tests/diagnostics/union-spread-merge.ts b/tests/diagnostics/union-spread-merge.ts new file mode 100644 index 000000000..dae422070 --- /dev/null +++ b/tests/diagnostics/union-spread-merge.ts @@ -0,0 +1,26 @@ +// A spread merge over UNION-typed sources: `{ ...base, ...override }` has one +// field set per combination of source arms, so the merged value can carry +// fields from several arms at once. No single arm of the destination union +// can represent it (naming one would drop the other arms' fields, which Node +// keeps), and no runtime tag would be honest. The refusal names the candidate +// arms; the repair is one arm per merge or one open all-optional record type. +interface OpenAiCompat { + kind: "openai"; + supportsStore?: boolean; +} + +interface AnthropicCompat { + kind: "anthropic"; + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +function mergeCompat(base: Compat | undefined, override: Compat | undefined): Compat | undefined { + if (!override) return base; + const merged = { ...base, ...override } as NonNullable; + return merged; +} + +const result = mergeCompat({ kind: "openai" }, { kind: "anthropic" }); +console.log(result !== undefined); diff --git a/tests/diagnostics/unmappable-declaration-initializer.ts b/tests/diagnostics/unmappable-declaration-initializer.ts new file mode 100644 index 000000000..823f430e6 --- /dev/null +++ b/tests/diagnostics/unmappable-declaration-initializer.ts @@ -0,0 +1,10 @@ +// An initializer is never dropped. This declaration's type has no lowering, +// so the declaration is refused where it is written — it cannot compile away +// with the call in its initializer, which would run under Node. +function touch(): number { + return 1; +} +function run(): void { + const ref: WeakRef = touch() as unknown as WeakRef; +} +run(); 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/declared-types.ts b/tests/fixtures/node-island/declared-types.ts new file mode 100644 index 000000000..05835c751 --- /dev/null +++ b/tests/fixtures/node-island/declared-types.ts @@ -0,0 +1,27 @@ +// A failed build whose package runs in a Node island reports the island +// build's errors: there the package's declarations type the program. The +// test builds it beside corpus 6829's schemakit package. +import { Num, check, type Static, type TNumber, type TSchema } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const double: Tool = { + name: "double", + parameters: Num(), + execute: (id, params) => `${id}: ${params * 2}`, +}; +const input: unknown = 4; +if (check(double.parameters, input)) console.log(double.execute(double.name, input)); +// The build's own error: member decorators have no native lowering. +function logged(method: () => number, context: ClassMethodDecoratorContext): void {} +class Counter { + @logged + next(): number { + return 1; + } +} +console.log(new Counter().next()); 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/__snapshots__/bigint-fences.ts.txt b/tests/harness/__snapshots__/bigint-fences.ts.txt index a33eb4ee6..3df7b8c95 100644 --- a/tests/harness/__snapshots__/bigint-fences.ts.txt +++ b/tests/harness/__snapshots__/bigint-fences.ts.txt @@ -23,20 +23,18 @@ bigint-fences.ts:8:28 - error SC1090: JSON.stringify of 'bigint' values (only nu | ^~ 9 | -bigint-fences.ts:11:39 - error SC2020: 'statSync with bigint: true' is part of the standard library types but has no scriptc lowering yet +bigint-fences.ts:14:14 - error SC1090: BigIntStats methods as values (call 'isFile' directly) are not supported yet - 10 | import { statSync } from "node:fs"; - 11 | const stats = statSync(".", { bigint: true }); - | ^~~~ - 12 | console.log(stats.size); + 13 | // has no lowering. + 14 | const read = stats.isFile; + | ^~~~~~~~~~~~ + 15 | console.log(stats.size); - hint: BigIntStats has no lowering; omit bigint for Stats +bigint-fences.ts:17:6 - error SC2004: uses of 'typed' inherit the blocker on its declaration -bigint-fences.ts:14:6 - error SC2004: uses of 'typed' inherit the blocker on its declaration - - 13 | - 14 | void typed; + 16 | + 17 | void typed; | ^~~~~ - 15 | void dynamicValue; + 18 | void dynamicValue; hint: the declaration of 'typed' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/function-forms.ts.txt b/tests/harness/__snapshots__/function-forms.ts.txt deleted file mode 100644 index 3c3bd5c83..000000000 --- a/tests/harness/__snapshots__/function-forms.ts.txt +++ /dev/null @@ -1,6 +0,0 @@ -function-forms.ts:38:3 - error SC1090: functions with optional or defaulted parameters as values, except where the target type spells the completed signature with required parameters (a '(x?: T) => R' function flows into a '(x: T | undefined) => R' slot, and a package/'any' slot takes any natively representable signature; otherwise call the function directly) are not supported yet - - 37 | function outer(): number { - 38 | function inner(n = 2): number { - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 39 | return n; \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt new file mode 100644 index 000000000..f4dce44f0 --- /dev/null +++ b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt @@ -0,0 +1,17 @@ +stdlib-snapshot-value-use.ts:7:9 - error SC2020: the stdlib-global snapshot 'proc' ('process') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and the global it names is not a value scriptc can hold + + 6 | function label(): unknown { + 7 | const proc = globalThis.process; + | ^~~~ + 8 | return proc; + + hint: read the members through the global directly ('process.member') instead of passing 'proc' around as a value + +stdlib-snapshot-value-use.ts:8:10 - error SC2004: uses of 'proc' inherit the blocker on its declaration + + 7 | const proc = globalThis.process; + 8 | return proc; + | ^~~~ + 9 | } + + hint: the declaration of 'proc' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib.ts.txt b/tests/harness/__snapshots__/stdlib.ts.txt index dbe79859a..83e222a99 100644 --- a/tests/harness/__snapshots__/stdlib.ts.txt +++ b/tests/harness/__snapshots__/stdlib.ts.txt @@ -3,13 +3,13 @@ stdlib.ts:8:40 - error SC1090: a builtin function alias as an escaping value (ca 7 | import { writeFile } from "node:fs/promises"; 8 | const read = readFileSync; console.log(read); | ^~~~ - 9 | const cwd = process.cwd; + 9 | const exit = process.exit; -stdlib.ts:9:13 - error SC1090: process methods as values (call 'cwd' directly) are not supported yet +stdlib.ts:9:14 - error SC1090: process methods as values (call 'exit' directly) are not supported yet 8 | const read = readFileSync; console.log(read); - 9 | const cwd = process.cwd; - | ^~~~~~~~~~~ + 9 | const exit = process.exit; + | ^~~~~~~~~~~~ 10 | const p = process; stdlib.ts:13:15 - error SC1090: string methods as values (call 'toUpperCase' directly) are not supported yet diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt index 8915968ef..da3819baf 100644 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ b/tests/harness/__snapshots__/union-clone-override-width.ts.txt @@ -1,6 +1,8 @@ -union-clone-override-width.ts:8:18 - error SC2001: values of type '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown') +union-clone-override-width.ts:8:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' can represent it (candidate arms: '{ kind: "left"; left: number; payload: WiderPayload; }', '{ kind: "right"; right: number; payload: WiderPayload; }') 7 | // copy its record. The inferred spread result keeps the wider payload. 8 | const result = { ...source, payload }; | ^~~~~~~~~~~~~~~~~~~~~~ - 9 | return result.payload.extra; \ No newline at end of file + 9 | return result.payload.extra; + + hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/__snapshots__/union-spread-merge.ts.txt b/tests/harness/__snapshots__/union-spread-merge.ts.txt new file mode 100644 index 000000000..e3b39e2b6 --- /dev/null +++ b/tests/harness/__snapshots__/union-spread-merge.ts.txt @@ -0,0 +1,8 @@ +union-spread-merge.ts:21:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') + + 20 | if (!override) return base; + 21 | const merged = { ...base, ...override } as NonNullable; + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 22 | return merged; + + hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt new file mode 100644 index 000000000..c4dc441ca --- /dev/null +++ b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt @@ -0,0 +1,8 @@ +unmappable-declaration-initializer.ts:8:9 - error SC2020: 'WeakRef' is part of the standard library types but has no scriptc lowering yet + + 7 | function run(): void { + 8 | const ref: WeakRef = touch() as unknown as WeakRef; + | ^~~ + 9 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file diff --git a/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..c2f7906fb --- /dev/null +++ b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 62000 } diff --git a/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json b/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json new file mode 100644 index 000000000..016c16de0 --- /dev/null +++ b/tests/harness/corpus-costs/7015-path-platform-namespace-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 28700 } diff --git a/tests/harness/corpus-costs/7016-union-field-delete.ts.json b/tests/harness/corpus-costs/7016-union-field-delete.ts.json new file mode 100644 index 000000000..450a8bf95 --- /dev/null +++ b/tests/harness/corpus-costs/7016-union-field-delete.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 4100 } diff --git a/tests/harness/corpus-costs/7017-nonnullable-union.ts.json b/tests/harness/corpus-costs/7017-nonnullable-union.ts.json new file mode 100644 index 000000000..a5a099a2e --- /dev/null +++ b/tests/harness/corpus-costs/7017-nonnullable-union.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2700 } diff --git a/tests/harness/corpus-costs/7018-bigint-stats.ts.json b/tests/harness/corpus-costs/7018-bigint-stats.ts.json new file mode 100644 index 000000000..3bf521f98 --- /dev/null +++ b/tests/harness/corpus-costs/7018-bigint-stats.ts.json @@ -0,0 +1 @@ +{ "plain": 4417, "san": 57812 } diff --git a/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json b/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json new file mode 100644 index 000000000..91df8199c --- /dev/null +++ b/tests/harness/corpus-costs/8423-accessor-member-kind-mismatch.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 3800 } diff --git a/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..e683aaf34 --- /dev/null +++ b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 24000 } 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/dyn-generator-method-value.js.json b/tests/harness/corpus-costs/dyn-generator-method-value.js.json new file mode 100644 index 000000000..cc16fb950 --- /dev/null +++ b/tests/harness/corpus-costs/dyn-generator-method-value.js.json @@ -0,0 +1 @@ +{ "plain": 1349, "san": 2585 } diff --git a/tests/harness/corpus-costs/erased-never-member-read.ts.json b/tests/harness/corpus-costs/erased-never-member-read.ts.json new file mode 100644 index 000000000..6cf92fddb --- /dev/null +++ b/tests/harness/corpus-costs/erased-never-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 4100 } 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/frozen-checked-records-sloppy.cjs.json b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..6289d836d --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1 @@ +{ "plain": 3590, "san": 12400 } diff --git a/tests/harness/corpus-costs/frozen-checked-records.ts.json b/tests/harness/corpus-costs/frozen-checked-records.ts.json new file mode 100644 index 000000000..1086c8461 --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records.ts.json @@ -0,0 +1 @@ +{ "plain": 2610, "san": 12260 } diff --git a/tests/harness/corpus-costs/function-name-const-alias.mjs.json b/tests/harness/corpus-costs/function-name-const-alias.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/function-name-const-alias.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } 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/optional-param-function-values.ts.json b/tests/harness/corpus-costs/optional-param-function-values.ts.json new file mode 100644 index 000000000..57d1f5243 --- /dev/null +++ b/tests/harness/corpus-costs/optional-param-function-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2950, "san": 4150 } diff --git a/tests/harness/corpus-costs/path-namespace-spreads.js.json b/tests/harness/corpus-costs/path-namespace-spreads.js.json new file mode 100644 index 000000000..cd64ba8d0 --- /dev/null +++ b/tests/harness/corpus-costs/path-namespace-spreads.js.json @@ -0,0 +1 @@ +{ "plain": 4411, "san": 10661 } diff --git a/tests/harness/corpus-costs/process-cwd-value.cjs.json b/tests/harness/corpus-costs/process-cwd-value.cjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/process-cwd-value.cjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/process-cwd-value.mjs.json b/tests/harness/corpus-costs/process-cwd-value.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/process-cwd-value.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } 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/corpus-costs/sloppy-this-global-computed.cjs.json b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..2249e0825 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 4550 } diff --git a/tests/harness/corpus-costs/sloppy-this-global.cjs.json b/tests/harness/corpus-costs/sloppy-this-global.cjs.json new file mode 100644 index 000000000..66f469d88 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global.cjs.json @@ -0,0 +1 @@ +{ "plain": 3030, "san": 3970 } diff --git a/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json b/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/tty-isatty-value-typed.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/tty-isatty-value.mjs.json b/tests/harness/corpus-costs/tty-isatty-value.mjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/tty-isatty-value.mjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } 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, + ); +}); diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 2aca7c86d..e3e82216c 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -308,7 +308,7 @@ describe.skipIf(skip)("Node islands in-process", () => { }); expect(result.code).toBe(1); expect(result.stderr).toBe( - `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 8)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`, ); }); @@ -355,6 +355,31 @@ test("recursive JSON types cross into Node", async () => { expect(ir).toContain("nodeIsland.callSync"); }, 300_000); +// When a build fails and the packages it compiled with runtime fences then +// fail as islands too, the island build's errors are reported. That build +// types the user code from the packages' declarations; the native one +// typed it from their JavaScript, where declared-only types (`Static`) +// are `any`. Needs no libnode pack: both builds fail before linking. +test("a failed build reports the island build's errors", async () => { + const dir = join(outDir, "declared-types"); + rmSync(dir, { recursive: true, force: true }); + cpSync( + join(repoRoot, "tests/corpus/6829-node-island-declared-types/node_modules"), + join(dir, "node_modules"), + { recursive: true }, + ); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-types.ts"), + join(dir, "main.ts"), + ); + const result = await compile(join(dir, "main.ts"), { outPath: join(dir, "main"), outDir: dir }); + expect(result.ok).toBe(false); + if (result.ok) return; + const messages = result.diagnostics.map((d) => `${d.code}: ${d.message}`); + expect(messages.some((m) => m.startsWith("SC1090: method decorators"))).toBe(true); + expect(messages.filter((m) => /Tool|Static|'check'/.test(m))).toEqual([]); +}, 300_000); + // Needs no libnode pack: the build fails before linking. test("re-exporting a path-mapped island package reports the island boundary", async () => { const dir = join(outDir, "path-mapped-reexport"); diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index 1463e3354..d31d96ac9 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -47,7 +47,7 @@ type Known = const KNOWN: Record = { "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, - "bytes-exit": { build: "SC2004" }, + "bytes-exit": { island: true }, "cjs-esm-named": { island: true }, "cjs-lexer-aligned": { island: true }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, @@ -214,6 +214,43 @@ async function build(entry: string, shapeOverlay: boolean | undefined): Promise< }; } +/** Node's uncaught-error report ends with `Node.js v`: the island + * prints libnode's release, which may differ from the reference Node's + * patch release. */ +function normalizeNodeVersion([a, b]: [string, string]): [string, string] { + const re = /^Node\.js v\d+\.\d+\.\d+$/m; + return [a.replace(re, "Node.js v"), b.replace(re, "Node.js v")]; +} + +/** The two stderr texts with their stack frame runs cut to the frames + * they share: an uncaught error from a Node island prints Node's report, + * whose frames match Node's from the throw site up to the first native + * frame, which has no Node-identical spelling (DESIGN.md §2.10). Every + * other line, and each run's common leading frames, compare exactly. */ +function sharedFrames(native: Buffer, node: Buffer): [string, string] { + const frame = (line: string): boolean => /^ {4}at /.test(line); + const a = native.toString("utf8").split("\n"); + const b = node.toString("utf8").split("\n"); + const outA: string[] = []; + const outB: string[] = []; + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && frame(a[i]!) && frame(b[j]!)) { + while (i < a.length && j < b.length && frame(a[i]!) && a[i] === b[j]) { + outA.push(a[i++]!); + outB.push(b[j++]!); + } + while (i < a.length && frame(a[i]!)) i++; + while (j < b.length && frame(b[j]!)) j++; + continue; + } + if (i < a.length) outA.push(a[i++]!); + if (j < b.length) outB.push(b[j++]!); + } + return [outA.join("\n"), outB.join("\n")]; +} + /** A Node island's modules run from the module tree the program ships * (node-islands/modules.ts), extracted under the user's cache directory, so * a path they print names the tree's copy. The tree's root is the fixtures' @@ -223,14 +260,14 @@ function fixturePaths(out: Buffer): Buffer { return Buffer.from(out.toString("utf8").replace(tree, join(fixturesRoot, "npm")), "utf8"); } -async function expectMatchesNode(c: NpmCase, binary: string, island = false): Promise { +async function expectMatchesNode(c: NpmCase, binary: string, islandFrames = false): Promise { const cwd = dirname(c.entry); for (const argv of c.argvs ?? [[]]) { const [nodeRes, ran] = await Promise.all([ runBinary(process.execPath, [c.entry, ...argv], cwd), runBinary(binary, argv, cwd), ]); - const nativeRes = island + const nativeRes = islandFrames ? { ...ran, stdout: fixturePaths(ran.stdout), stderr: fixturePaths(ran.stderr) } : ran; const label = argv.join(" "); @@ -239,7 +276,13 @@ async function expectMatchesNode(c: NpmCase, binary: string, island = false): Pr expect.unreachable("stdout differed at byte level but not after utf8 decode"); } expect(nativeRes.exitCode, label).toBe(nodeRes.exitCode); - if (c.compareStderr) expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); + if (c.compareStderr && islandFrames) { + const [native, node] = normalizeNodeVersion( + sharedFrames(programStderr(nativeRes.stderr), nodeRes.stderr), + ); + expect(native, label).toBe(node); + } else if (c.compareStderr) + expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); } }