From 1d7ddf7391f8e9ffd081d3d0a2ff2a83262715d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:18 -0700 Subject: [PATCH 01/15] Lower `?.` method calls on dynamic receivers and declared-optional capture receivers `this.cache?.warm()` refused whenever the receiver lowered to a dynamic value: the checked-dynamic receiver path treated any `?.` step as unhandled chain work and bailed, even inside a chain body whose guard had already proven the receiver present. Those steps are now gated on `chainBlocked`, the canonical "is this `?.` still unhandled" test, so the guarded body is the plain read-and-call it already is, with `dynCall` throwing Node's catchable "x.y is not a function" for a present but non-callable member. The string-only and primitive-method branches drop their raw `questionDotToken` checks the same way. A capture the checker itself types with an absent arm (`T | undefined`, an optional property or parameter) also refused as a "complex" use in a logical or conditional receiver, even though the union value flows into the surrounding operator, which handles the absent arm itself. The use now lowers to a plain read when both the checked type and the recorded storage carry an undefined arm; a hidden absence keeps its refusal. Corpus 8500 and 8501 pin optional method-call receivers and `(entries ?? []).map(...)`-shaped capture receivers against Node. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 15 ++++ .../src/frontend/lowering/lower-calls.ts | 15 ++-- ...500-optional-method-call-receivers.ts.json | 6 ++ ...nullish-optional-capture-receivers.ts.json | 6 ++ .../8500-optional-method-call-receivers.ts | 79 +++++++++++++++++++ ...8501-nullish-optional-capture-receivers.ts | 28 +++++++ ...500-optional-method-call-receivers.ts.json | 1 + ...nullish-optional-capture-receivers.ts.json | 1 + 8 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json create mode 100644 tests/corpus/8500-optional-method-call-receivers.ts create mode 100644 tests/corpus/8501-nullish-optional-capture-receivers.ts create mode 100644 tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json create mode 100644 tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..4767f9b7b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -236,6 +236,21 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: if (active || captured) { const use = runtimeOptionalUseOf(expr); if (use?.complex) { + // A DECLARED absent arm is honest storage: when the checker itself + // types this use with a unit arm (an optional property read, an + // optional parameter, a `T | undefined` local), the read needs no + // narrowing aid — the union value flows into the surrounding + // logical/conditional operator, which handles the absent arm + // itself (`(entries ?? []).map(...)`, `x?.y ?? z`). Only a hidden + // absence the checker does not admit needs the refusal below. + const checked = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if ( + checked?.kind === "union" && + local.type.kind === "union" && + lowerer.armTag(checked.unionId, UNDEFINED_T) >= 0 && + lowerer.armTag(local.type.unionId, UNDEFINED_T) >= 0 + ) + return { kind: "varRef", localId: local.id, type: local.type, loc }; lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fcdce59f0..955b57610 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4036,8 +4036,7 @@ function lowerDynReceiverMethodCall( if ( (checkerUntyped || (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && - !call.questionDotToken && - !access.questionDotToken && + !lowerer.chainBlocked(call, access) && !call.arguments.nodes.some(ts.isSpreadElement) ) return lowerStringOrMemberCall(lowerer, call, access, recv); @@ -4053,8 +4052,6 @@ function lowerDynReceiverMethodCall( // runtime dispatch. if ( !ownCallable && - !call.questionDotToken && - !access.questionDotToken && isDynPrimitiveKindMethod(access.name.text) && !lowerer.chainBlocked(access, call) ) { @@ -4202,9 +4199,13 @@ function lowerDynReceiverMethodCall( !stringOnlyName ) return null; - // Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain - // machinery's short-circuit semantics — not modeled here yet. - if (call.questionDotToken || access.questionDotToken) return null; + // An UNHANDLED `?.` step (`obj.cb?.()`, `obj?.cb()`) belongs to the chain + // machinery's short-circuit semantics. Inside the guarded body of a chain + // the active lowering has ALREADY proven the receiver present, so the + // step is a plain read-and-call there — the same "read the member, call + // it" shape below, with `dynCall` throwing Node's catchable + // "obj.cb is not a function" for a present but non-callable member. + if (lowerer.chainBlocked(call, access)) return null; const loc = locOf(call); const receiverLocal = lowerer.declareHiddenLocal("%dynCallReceiver", DYN); const receiver: IrExpr = { kind: "varRef", localId: receiverLocal.id, type: DYN, loc }; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json new file mode 100644 index 000000000..5b5c4b640 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8500-optional-method-call-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8500-optional-method-call-receivers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json new file mode 100644 index 000000000..e90405629 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8501-nullish-optional-capture-receivers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8501-nullish-optional-capture-receivers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8500-optional-method-call-receivers.ts b/tests/corpus/8500-optional-method-call-receivers.ts new file mode 100644 index 000000000..ddf480641 --- /dev/null +++ b/tests/corpus/8500-optional-method-call-receivers.ts @@ -0,0 +1,79 @@ +// Optional-chained METHOD calls on structural receivers: a class seen +// through `Pick`, an interface implemented by program classes, +// and the same receivers held in optional fields. Node calls the method +// when the receiver is present and answers undefined (no call) when not. + +class CacheWarmer { + status = "idle"; + onAgentSettled(): void { + console.log("settled", this.status); + } + onModeChanged(): void { + console.log("mode", this.status); + } +} + +interface Renderer { + requestRender(): void; +} + +class MainScreen implements Renderer { + requestRender(): void { + console.log("main"); + } +} + +class AltScreen implements Renderer { + requestRender(): void { + console.log("alt"); + } +} + +function settle(w: Pick | undefined): void { + w?.onAgentSettled(); +} + +function tick(tui: Renderer | undefined): void { + tui?.requestRender(); +} + +class Session { + private cacheWarmer?: Pick; + private renderer: Renderer | undefined; + constructor(w: CacheWarmer | undefined, r: Renderer | undefined) { + this.cacheWarmer = w; + this.renderer = r; + } + settle(): void { + this.cacheWarmer?.onModeChanged(); + this.renderer?.requestRender(); + } + describe(): string { + return `${this.cacheWarmer?.status ?? "none"}/${this.renderer === undefined ? "none" : "ui"}`; + } +} + +settle(new CacheWarmer()); +settle(undefined); +tick(new MainScreen()); +tick(new AltScreen()); +tick(undefined); +new Session(new CacheWarmer(), new MainScreen()).settle(); +new Session(undefined, undefined).settle(); +console.log(new Session(new CacheWarmer(), undefined).describe()); +console.log(new Session(undefined, new AltScreen()).describe()); + +// A present but non-callable member still throws Node's catchable TypeError; +// the `?.` step only skips the call when the RECEIVER is absent. +const loose: any = { render: 42 }; +try { + loose?.render(); +} catch (e) { + console.log("call:", e instanceof TypeError, (e as Error).message); +} +const looseStep: any = { render: 42 }; +try { + looseStep.render?.(); +} catch (e) { + console.log("step:", e instanceof TypeError, (e as Error).message); +} diff --git a/tests/corpus/8501-nullish-optional-capture-receivers.ts b/tests/corpus/8501-nullish-optional-capture-receivers.ts new file mode 100644 index 000000000..e4079dc5d --- /dev/null +++ b/tests/corpus/8501-nullish-optional-capture-receivers.ts @@ -0,0 +1,28 @@ +// `(x ?? fallback).method(...)` over storage the checker types +// `T | undefined`: the default takes over exactly when the stored value is +// absent — an optional record field, an optional parameter, a callback +// parameter, and an unchecked element read. + +interface Options { + tools?: string[]; +} + +const DEFAULTS = ["read", "write"]; + +function active(options: Options): string[] { + const selected = options.tools; + return (selected ?? DEFAULTS).filter((name) => name !== "write"); +} + +function collect(...failures: Array): string[] { + return failures.flatMap((entries) => (entries ?? []).map((entry) => entry.toUpperCase())); +} + +function totals(groups: number[][] | undefined): number { + return (groups ?? []).reduce((sum, group) => sum + group.length, 0); +} + +console.log(active({ tools: ["read", "write"] }).join(",")); +console.log(active({}).join(",")); +console.log(collect(["a"], undefined, ["b"]).join(",")); +console.log(totals([[1, 2], [3]]), totals(undefined)); diff --git a/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json b/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json new file mode 100644 index 000000000..feabbe802 --- /dev/null +++ b/tests/harness/corpus-costs/8500-optional-method-call-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 4200 } diff --git a/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json b/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json new file mode 100644 index 000000000..8ee6a84fa --- /dev/null +++ b/tests/harness/corpus-costs/8501-nullish-optional-capture-receivers.ts.json @@ -0,0 +1 @@ +{ "plain": 2850, "san": 4300 } From d414d518b38f634b0e0929464f1659cf2425067c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:29 -0700 Subject: [PATCH 02/15] Fold constant module specifiers in dynamic import() and import.meta.resolve Module resolution is a build-time artifact, but only a string literal counted as a static specifier: `const FS = "node:" + "fs"; import(FS)` and `import("node:" + "path")` fell into the computed-specifier refusals. pi's `packages/ai` env loader writes exactly that shape. `staticString` now folds a literal, a cast or parenthesized form of one, a `+` concatenation of those, and a `const` binding whose initializer folds to one (depth-bounded); anything only known at run time still returns null, so genuinely computed specifiers keep their refusal. `import()`, `import.meta.resolve` and `createRequire`'s `resolve` share it. A folded specifier carries no module symbol on its argument node, so the own-module test and the import target fall back to the checker's program import resolution. Corpus 8502 pins both folded forms and the builtins they name. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/module-resolution.ts | 40 ++++++++++++++--- .../src/frontend/lowering/lower-modules.ts | 15 +++++-- .../src/frontend/lowering/lower-stdlib.ts | 43 +++++++++++++------ .../8502-constant-module-specifiers.ts.json | 6 +++ .../corpus/8502-constant-module-specifiers.ts | 23 ++++++++++ .../8502-constant-module-specifiers.ts.json | 1 + 6 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json create mode 100644 tests/corpus/8502-constant-module-specifiers.ts create mode 100644 tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts index 3911188e4..4e7234a48 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts @@ -467,10 +467,40 @@ export function lowerCreateRequireCall( } } -function staticString(node: ts.Expression | undefined): string | null { - if (node === undefined) return null; +/** The statically-known text of a module specifier expression. Module + * resolution is a build-time artifact, so only a value the compiler already + * knows can name a module: a string literal (or substitution-free + * template), a parenthesized or cast form of one, a `+` concatenation of + * those, or a `const` binding whose initializer folds to one + * (`const FS = "node:" + "fs"; import(FS)`). Null when the text is only + * known at run time. */ +export function staticString( + lowerer: Lowerer, + node: ts.Expression | undefined, + depth = 0, +): string | null { + if (node === undefined || depth > 8) return null; const value = stripTypeCasts(node); - return ts.isStringLiteralLike(value) ? value.text : null; + if (ts.isStringLiteralLike(value)) return value.text; + if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = staticString(lowerer, value.left, depth + 1); + if (left === null) return null; + const right = staticString(lowerer, value.right, depth + 1); + return right === null ? null : left + right; + } + if (ts.isIdentifier(value)) { + const decl = lowerer.checker.getSymbolAtLocation(value)?.valueDeclaration; + if ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + decl.initializer !== value && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ) + return staticString(lowerer, decl.initializer, depth + 1); + } + return null; } function runtimeResolveThrow(error: RuntimeResolveError, type: IrType, loc: SrcLoc): IrExpr { @@ -503,7 +533,7 @@ export function lowerImportMetaResolveCall( 'the lowered form is import.meta.resolve("") using the containing module as its parent', ); } - const specifier = staticString(call.arguments.nodes[0]); + const specifier = staticString(lowerer, call.arguments.nodes[0]); if (specifier === null) { lowerer.noLowering( "import.meta.resolve with a runtime-computed specifier", @@ -618,7 +648,7 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio call, ); } - const specifier = staticString(call.arguments.nodes[0]); + const specifier = staticString(lowerer, call.arguments.nodes[0]); // A computed request (or options) through a createRequire loader in // JavaScript source resolves at run time. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3b2110290..39cc6aaf6 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -4,6 +4,7 @@ import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js"; import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; import { collectIslandImport, collectIslandRequires } from "./lower-node-island.js"; +import { staticString } from "./builtins/module-resolution.js"; import { NODE_API_MODULE } from "../node-api-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -779,9 +780,10 @@ function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.nodes.length >= 1 && node.arguments.nodes[0] !== undefined && - ts.isStringLiteralLike(node.arguments.nodes[0]) + staticString(lowerer, node.arguments.nodes[0]) !== null ) { - const spec = node.arguments.nodes[0].text; + // A constant expression names its module as surely as a literal. + const spec = staticString(lowerer, node.arguments.nodes[0]!)!; if (lowerer.externalTypes.has(spec)) { return undefined; } @@ -800,9 +802,14 @@ function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { return undefined; } const modSym = lowerer.checker.getSymbolAtLocation(node.arguments.nodes[0]); + // A folded specifier carries no module symbol on its argument (the + // text comes from a `const`, not a literal), so fall back to the + // checker's program import resolution. + const folded = resolveImport(lowerer.program, sf, spec); const ownModule = - modSym !== undefined && - modSym.declarations.some((d) => ts.isSourceFile(d) && !d.isDeclarationFile); + (modSym !== undefined && + modSym.declarations.some((d) => ts.isSourceFile(d) && !d.isDeclarationFile)) || + (folded !== null && !folded.isDeclarationFile); const builtin = canonicalBuiltinModule(spec); const res = ownModule ? ({ kind: "program-module" } as const) diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 44f2b7370..c15a291ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2,6 +2,8 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import { webStreamClassKindOf } from "./builtins/web-streams-dyn.js"; +import { staticString } from "./builtins/module-resolution.js"; +import { resolveImport } from "../program.js"; import * as ts from "../ts7/adapter.js"; import { lowerToNumberArguments } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; @@ -2229,7 +2231,11 @@ export function lowerStaticReadableStreamReaderCall( export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return null; const arg = call.arguments.nodes[0]; - if (arg === undefined || !ts.isStringLiteralLike(arg)) { + // The specifier text must be known at build time; a constant expression + // (`import("node:" + "fs")`, `import(FS)`) names its module just as a + // literal does. collectDynamicImports keys its table by the same text. + const specifier = arg === undefined ? null : staticString(lowerer, arg); + if (specifier === null) { lowerer.unsupported( "SC1090", call, @@ -2242,11 +2248,11 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression if (call.arguments.nodes.length !== 1) { lowerer.unsupported("SC1090", call, "dynamic import() with import attributes"); } - const res = lowerer.dynImports.get(`${ts.getSourceFileOfNode(call).fileName}\u0000${arg.text}`); + const res = lowerer.dynImports.get(`${ts.getSourceFileOfNode(call).fileName}\u0000${specifier}`); if (res === undefined) lowerer.refuseUnsupportedApi("'import()'", call); if (res.kind === "rejected") return lowerRejectedImport(lowerer, call, res.crash); if (res.kind === "program-module") { - return lowerOwnStaticModuleImport(lowerer, call, arg); + return lowerOwnStaticModuleImport(lowerer, call, specifier); } return lowerStaticNamespaceImport(lowerer, call, { kind: "moduleNs", @@ -2317,13 +2323,24 @@ function lowerRejectedImport( }; } -/** The checker-resolved program source behind one literal import(). */ -function programImportTarget(lowerer: Lowerer, arg: ts.StringLiteralLike): ts.SourceFile | null { - const modSym = lowerer.checker.getSymbolAtLocation(arg); - for (const d of modSym ? modSym.declarations : []) { - if (ts.isSourceFile(d) && !d.isDeclarationFile) return d; +/** The compiled module a literal specifier names. A FOLDED specifier + * (a `const` or a `+` concatenation) carries no module symbol on its + * argument node, so it resolves through the checker's program import + * resolution instead; only modules already in the program resolve. */ +function programImportTarget( + lowerer: Lowerer, + call: ts.CallExpression, + specifier: string, +): ts.SourceFile | null { + const arg = call.arguments.nodes[0]; + if (arg !== undefined && ts.isStringLiteralLike(arg)) { + const modSym = lowerer.checker.getSymbolAtLocation(arg); + for (const d of modSym ? modSym.declarations : []) { + if (ts.isSourceFile(d) && !d.isDeclarationFile) return d; + } } - return null; + const resolved = resolveImport(lowerer.program, ts.getSourceFileOfNode(call), specifier); + return resolved !== null && !resolved.isDeclarationFile ? resolved : null; } /** Static literal import of one compiled ESM module. The async helper's @@ -2334,14 +2351,14 @@ function programImportTarget(lowerer: Lowerer, arg: ts.StringLiteralLike): ts.So function lowerOwnStaticModuleImport( lowerer: Lowerer, call: ts.CallExpression, - arg: ts.StringLiteralLike, + specifier: string, ): IrExpr { - const dep = programImportTarget(lowerer, arg); + const dep = programImportTarget(lowerer, call, specifier); if (dep !== null && (dep.fileName.endsWith(".cts") || isCjsJsFile(dep, lowerer.program))) { lowerer.unsupported( "SC1090", call, - `dynamic import of the program's own CommonJS module '${arg.text}' ` + + `dynamic import of the program's own CommonJS module '${specifier}' ` + "(its namespace comes from module.exports through Node's CJS lexer, " + "which has no compiled story — require it, or import it statically)", ); @@ -2350,7 +2367,7 @@ function lowerOwnStaticModuleImport( lowerer.unsupported( "SC1090", call, - `dynamic import of the program's own module '${arg.text}' ` + + `dynamic import of the program's own module '${specifier}' ` + "(this module is not part of the compiled module graph)", ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json new file mode 100644 index 000000000..8b78bd3c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-constant-module-specifiers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8502-constant-module-specifiers.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8502-constant-module-specifiers.ts b/tests/corpus/8502-constant-module-specifiers.ts new file mode 100644 index 000000000..f3bf78c07 --- /dev/null +++ b/tests/corpus/8502-constant-module-specifiers.ts @@ -0,0 +1,23 @@ +// Module specifiers the compiler can evaluate: a `const` binding and a `+` +// concatenation of literals name their module exactly as a string literal +// does, so dynamic import() and import.meta.resolve stay build-time +// resolutions instead of computed-specifier refusals. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const FS_SPECIFIER = "node:" + "fs"; +const PATH_SPECIFIER = "node:path" as const; +const PREFIX = "node:"; +const JOIN_SPECIFIER = PREFIX + "path"; + +async function loadBuiltin(): Promise { + const fs = await import(FS_SPECIFIER); + const path = await import(JOIN_SPECIFIER); + console.log(fs.existsSync("/"), fs.existsSync("/definitely-not-here-xyz")); + console.log(path.join("a", "b") === join("a", "b"), path.basename("/a/b.ts")); +} + +await loadBuiltin(); +console.log(import.meta.resolve(PATH_SPECIFIER).startsWith("node:path")); +console.log(import.meta.resolve(FS_SPECIFIER).startsWith("node:fs")); diff --git a/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json b/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json new file mode 100644 index 000000000..a23eac02f --- /dev/null +++ b/tests/harness/corpus-costs/8502-constant-module-specifiers.ts.json @@ -0,0 +1 @@ +{ "plain": 2750, "san": 4100 } From 55cbeac6c69d5c11a610bcd604e09966516f777d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:24:34 -0700 Subject: [PATCH 03/15] Pin the native-shim monkeypatching refusal boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi's compatibility shims patch host objects — `process.stdout.write = fn` to capture output, `.bind()` on a native function, `Object.defineProperty` on a module export. A compiled binary has no host object to patch: the natives are direct calls, so these sites keep an explicit refusal instead of compiling to something that silently diverges from Node. tests/diagnostics/native-shim-monkeypatching.ts pins the three shapes (SC1090 assignment to a non-variable, SC2007 on a valued overload set, SC2020 for defineProperty on an import), so the boundary is visible and any future process-object model has to update it deliberately. Co-Authored-By: Claude Opus 5.5 --- .../native-shim-monkeypatching.ts.json | 6 +++ .../diagnostics/native-shim-monkeypatching.ts | 33 ++++++++++++ .../native-shim-monkeypatching.ts.txt | 51 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json create mode 100644 tests/diagnostics/native-shim-monkeypatching.ts create mode 100644 tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json new file mode 100644 index 000000000..c1f13438c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/native-shim-monkeypatching.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/native-shim-monkeypatching.ts b/tests/diagnostics/native-shim-monkeypatching.ts new file mode 100644 index 000000000..c587bb241 --- /dev/null +++ b/tests/diagnostics/native-shim-monkeypatching.ts @@ -0,0 +1,33 @@ +// Patching a NATIVE host function: Node lets a library swap +// `process.stdout.write`, hand out `process.stdout.write.bind(...)`, or +// redefine a module member with Object.defineProperty. A compiled binary +// has no host object to patch — the natives are direct calls — so each form +// keeps an explicit refusal instead of silently patching nothing. The +// working split: call the function directly, or give the library a callback +// the program owns. + +import * as fs from "node:fs"; + +export function guardStdout(): void { + process.stdout.write = (chunk: string | Uint8Array): boolean => { + process.stderr.write(chunk); + return true; + }; +} + +export function bindStdoutWrite(): typeof process.stdout.write { + return process.stdout.write.bind(process.stdout); +} + +export function bindReadFileSync(): typeof fs.readFileSync { + return fs.readFileSync.bind(fs); +} + +export function defineReadFileSync(): void { + Object.defineProperty(fs, "readFileSync", { value: () => "" }); +} + +guardStdout(); +bindStdoutWrite(); +bindReadFileSync(); +defineReadFileSync(); diff --git a/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt new file mode 100644 index 000000000..8fb13eab8 --- /dev/null +++ b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt @@ -0,0 +1,51 @@ +native-shim-monkeypatching.ts:12:3 - error SC1090: assignment to non-variables are not supported yet + + 11 | export function guardStdout(): void { + 12 | process.stdout.write = (chunk: string | Uint8Array): boolean => { + | ^~~~~~~~~~~~~~~~~~~~ + 13 | process.stderr.write(chunk); + +native-shim-monkeypatching.ts:18:17 - error SC2007: values of type '{ (data: string | Uint8Array, callback?: ((error?: Error | null | undefined) => void) | undefined): boolean; (data: string | Uint8Array<...>, encoding: BufferEncoding, callback?: ((error?: Error | ... 1 more ... | undefined) => void) | undefined): boolean; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 17 | + 18 | export function bindStdoutWrite(): typeof process.stdout.write { + | ^~~~~~~~~~~~~~~ + 19 | return process.stdout.write.bind(process.stdout); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +native-shim-monkeypatching.ts:22:17 - error SC2007: values of type '{ (path: string | number | URL, encoding: BufferEncoding): string; (path: string | number | URL, options: { encoding: BufferEncoding; }): string; (path: string | ... 1 more ... | URL): Buffer<...>; (path: string | URL, options: unknown): string | Buffer<...>; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature + + 21 | + 22 | export function bindReadFileSync(): typeof fs.readFileSync { + | ^~~~~~~~~~~~~~~~ + 23 | return fs.readFileSync.bind(fs); + + hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow + +native-shim-monkeypatching.ts:27:3 - error SC2020: 'Object.defineProperty' is part of the standard library types but has no scriptc lowering yet + + 26 | export function defineReadFileSync(): void { + 27 | Object.defineProperty(fs, "readFileSync", { value: () => "" }); + | ^~~~~~~~~~~~~~~~~~~~~ + 28 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +native-shim-monkeypatching.ts:31:1 - error SC2004: uses of 'bindStdoutWrite' inherit the blocker on its declaration + + 30 | guardStdout(); + 31 | bindStdoutWrite(); + | ^~~~~~~~~~~~~~~ + 32 | bindReadFileSync(); + + hint: the declaration of 'bindStdoutWrite' did not compile — fix the diagnostic reported there and these sites clear with it + +native-shim-monkeypatching.ts:32:1 - error SC2004: uses of 'bindReadFileSync' inherit the blocker on its declaration + + 31 | bindStdoutWrite(); + 32 | bindReadFileSync(); + | ^~~~~~~~~~~~~~~~ + 33 | defineReadFileSync(); + + hint: the declaration of 'bindReadFileSync' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file From e77e04c0469a8791edfc977123a4846aee7b3a4a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:36:30 -0700 Subject: [PATCH 04/15] Lower process prependListener registrations, server.off, and optional child chains process.prependListener now registers at the FRONT of the signal, 'exit', and 'uncaughtException' listener lists instead of refusing: the runtime gained scr_signal_prepend and scr_process_prepend_exit (both memmove the existing entries down one slot), and the uncaught-exception registry shares one prepend implementation with the append path (scr_rej_push_at). Families with an append-only registry ('message', 'disconnect', 'unhandledRejection', 'rejectionHandled', 'warning') and prependOnceListener still refuse rather than reordering silently. Server.off/removeListener unregister the most recently added matching listener from each of the server's lists (error, close/listening, connection/secureConnection, timeout, drop) through scr_net_server_off, releasing the callback and its once-marker; the emit forms keep their existing lowering. A child handle that is a `ChildProcess | undefined` field or parameter now takes `child?.kill(...)` / `child?.on(...)`: both were gated on a questionDotToken that the optional-chain lowering has already consumed, so the chain-blocked check (lowerer.chainBlocked) replaces it. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/net.d.ts | 17 ++++++ .../ambient/scriptc-node-fallback.d.ts | 6 ++ .../compiler/src/backend/llvm/lib-process.ts | 11 +++- .../compiler/src/backend/llvm/lib-shared.ts | 6 ++ .../builtins/child-process-methods.ts | 4 +- .../src/frontend/lowering/builtins/process.ts | 59 +++++++++++++++++-- .../src/frontend/lowering/lower-server.ts | 48 +++++++++++++++ packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 17 ++++++ packages/compiler/src/ir/runtime-features.ts | 3 + .../8423-process-prepend-listeners.ts.json | 6 ++ .../corpus/8424-child-optional-chain.ts.json | 6 ++ .../tests/corpus/8425-server-off.ts.json | 6 ++ packages/runtime/src/scr_async_dyn.c | 41 +++++++++++-- packages/runtime/src/scr_events.c | 45 +++++++++++++- packages/runtime/src/scr_events.h | 2 + packages/runtime/src/scr_exception.h | 1 + packages/runtime/src/scr_net.c | 24 ++++++++ packages/runtime/src/scr_net.h | 12 ++++ .../corpus/8423-process-prepend-listeners.ts | 24 ++++++++ tests/corpus/8424-child-optional-chain.ts | 27 +++++++++ tests/corpus/8425-server-off.ts | 27 +++++++++ 22 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json create mode 100644 tests/corpus/8423-process-prepend-listeners.ts create mode 100644 tests/corpus/8424-child-optional-chain.ts create mode 100644 tests/corpus/8425-server-off.ts diff --git a/packages/compiler/ambient/node-fallback/net.d.ts b/packages/compiler/ambient/node-fallback/net.d.ts index 806039fb5..3520518e7 100644 --- a/packages/compiler/ambient/node-fallback/net.d.ts +++ b/packages/compiler/ambient/node-fallback/net.d.ts @@ -257,6 +257,23 @@ declare module "net" { event: "request" | "checkContinue" | "checkExpectation", listener: (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void, ): void; + /* off / removeListener: the LAST listener standing for the same + * function leaves that event's list (Node's EventEmitter order). The + * server registries that keep a removable list: error, close, + * listening, connection/secureConnection, timeout, and drop. */ + off(event: "error", listener: (err: Error) => void): void; + off(event: "close" | "listening", listener: () => void): void; + off(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void; + off(event: "timeout", listener: (socket: Socket) => void): void; + off(event: "drop", listener: (data?: DropArgument) => void): void; + removeListener(event: "error", listener: (err: Error) => void): void; + removeListener(event: "close" | "listening", listener: () => void): void; + removeListener( + event: "connection" | "secureConnection", + listener: (socket: Socket) => void, + ): void; + removeListener(event: "timeout", listener: (socket: Socket) => void): void; + removeListener(event: "drop", listener: (data?: DropArgument) => void): void; } /* The 'drop' payload: the turned-away connection's addresses. */ export interface DropArgument { diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..e406cece3 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -374,6 +374,12 @@ declare var process: { * removal see the same value. */ on(event: "SIGINT" | "SIGTERM", listener: () => void): void; on(event: "exit", listener: (code: number) => void): void; + /* prependListener — the same registration at the FRONT of the event's + * listener list (Node's EventEmitter order), for the registries that + * own a prepend entry ('exit' and the signal family). prependOnceListener + * has no lowering. */ + prependListener(event: "SIGINT" | "SIGTERM", listener: () => void): void; + prependListener(event: "exit", listener: (code: number) => void): void; /* 'warning' — Node's process-warning channel: listeners receive the * warning Error (as a dyn value); emitWarning and the runtime's * deprecation sites dispatch synchronously (SEMANTICS.md). */ diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 01efd3354..a33bf9cb9 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -679,17 +679,22 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.emitPendingCheck(); return { name: "", type: e.type }; } - if (e.fn === "process.onExit") { + if (e.fn === "process.onExit" || e.fn === "process.prependExit") { // Runtime adapters cover both shapes (the code is a plain double); // the registry owns the callback. const cbT = e.args[0]!.type; if (cbT.kind !== "func") - throw new InternalCompilerError("llvm emitter bug: process.onExit callback not a func"); + throw new InternalCompilerError(`llvm emitter bug: ${e.fn} callback not a func`); const cb = host.emitExpr(e.args[0]!); - const once = host.emitExpr(e.args[1]!); host.toHeap(cb); const adapter = cbT.params.length === 0 ? "scr_exit_thunk0" : "scr_exit_thunk_code"; host.declare(`declare void @${adapter}(ptr, double)`); + if (e.fn === "process.prependExit") { + host.declare(`declare void @scr_process_prepend_exit(ptr, ptr)`); + B.line(`call void @scr_process_prepend_exit(ptr ${cb.name}, ptr @${adapter})`); + return { name: "", type: e.type }; + } + const once = host.emitExpr(e.args[1]!); host.declare(`declare void @scr_process_on_exit(ptr, ptr, i1 zeroext)`); B.line(`call void @scr_process_on_exit(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); return { name: "", type: e.type }; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index f2646e945..cadeac305 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -838,6 +838,11 @@ export const LIB_FN_SYMS: Record = { // identity); registration is special-cased above (ownership moves). "process.offSignal": "scr_signal_off", "process.offExit": "scr_process_off_exit", + // Front-of-list registrations (process.prependListener): the signal and + // uncaught-exception registries. prependExit is special-cased below with + // the exit adapters, like onExit. + "process.prependSignal": "scr_signal_prepend", + "process.prependUncaughtException": "scr_process_prepend_uncaught_exception", // child_process (scr_child.c): spawnSync/exec forms are synchronous // (execSync/execCapture throw — the may-throw check); handle getters // are pure reads. Registration forms are special-cased (moves + per- @@ -1420,6 +1425,7 @@ export const LIB_FN_SYMS: Record = { "net.serverListening": "scr_net_server_listening", "net.sockSetRef": "scr_net_sock_set_ref", "net.serverSetRef": "scr_net_server_set_ref", + "net.serverOff": "scr_net_server_off", "net.sockWrite": "scr_net_sock_write_str", "net.sockWriteBytes": "scr_net_sock_write_bytes", "net.sockEnd": "scr_net_sock_end", diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index cdd0b73c0..c1a6ea89b 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -31,7 +31,9 @@ export function lowerChildMethodCall( call: ts.CallExpression, access: ts.PropertyAccessExpression, ): IrExpr | null { - if (call.questionDotToken || access.questionDotToken) return null; + // A `?.` in the chain re-dispatches here with the receiver bound to the + // chain (optional-chains.ts), so only an UNHANDLED token declines. + if (lowerer.chainBlocked(call, access)) return null; if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "child") return null; if (!isChildSurfaceMember(lowerer, access)) return null; const name = access.name.text; diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..bd51e72e3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -1307,13 +1307,33 @@ export function lowerProcessMethodCall( // the listener to a const so both sites see the same value), and // `removeListener` IS `off` — Node aliases them. const isOff = member === "off" || member === "removeListener"; - if (member === "on" || member === "once" || isOff) { + // process.prependListener(event, cb): Node's front-of-list registration. + // The signal, 'exit', and 'uncaughtException'/'uncaughtExceptionMonitor' + // registries have a prepend entry; every other family registers by + // appending only, so it refuses rather than reordering silently. + // prependOnceListener has no runtime entry at all. + const isPrepend = member === "prependListener"; + if (member === "prependOnceListener") { + lowerer.noLowering( + "process.prependOnceListener", + access, + "use prependListener or once (prependOnceListener has no lowering)", + ); + } + if (member === "on" || member === "once" || isOff || isPrepend) { if (call.arguments.nodes.length !== 2) { lowerer.noLowering(`process.${member} with ${call.arguments.nodes.length} arguments`, call); } const evT = lowerer.typeOf(call.arguments.nodes[0]!); const event = evT.isStringLiteralType() ? evT.value : null; if ((event === "message" || event === "disconnect") && !isOff) { + if (isPrepend) { + lowerer.noLowering( + `process.prependListener("${event}", ...)`, + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1341,8 +1361,16 @@ export function lowerProcessMethodCall( const monitor = boolLit(event === "uncaughtExceptionMonitor", loc); return { kind: "libCall", - fn: isOff ? "process.offUncaughtException" : "process.onUncaughtException", - args: isOff ? [cb, monitor] : [cb, boolLit(member === "once", loc), monitor], + fn: isOff + ? "process.offUncaughtException" + : isPrepend + ? "process.prependUncaughtException" + : "process.onUncaughtException", + args: isOff + ? [cb, monitor] + : isPrepend + ? [cb, monitor] + : [cb, boolLit(member === "once", loc), monitor], type: VOID, loc, }; @@ -1356,6 +1384,13 @@ export function lowerProcessMethodCall( // a handler attached after delivery fires it once, synchronously // with the promise. if (event === "unhandledRejection" || event === "rejectionHandled") { + if (isPrepend) { + lowerer.noLowering( + `process.prependListener("${event}", ...)`, + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1379,6 +1414,13 @@ export function lowerProcessMethodCall( // util.deprecate dispatch on the next tick. off/removeListener remove // by closure identity. if (event === "warning" && (member === "on" || isOff)) { + if (isPrepend) { + lowerer.noLowering( + 'process.prependListener("warning", ...)', + call, + "prependListener is supported for signals, 'exit', and 'uncaughtException' (register this family with on)", + ); + } if (!resultIsDiscarded(call)) { lowerer.unsupported( "SC1090", @@ -1413,8 +1455,12 @@ export function lowerProcessMethodCall( const cb = lowerDiagnosticsSubscriber(lowerer, call.arguments.nodes[1]!); return { kind: "libCall", - fn: isOff ? "process.offSignal" : "process.onSignal", - args: isOff ? [signal, cb] : [signal, cb, boolLit(member === "once", loc)], + fn: isOff ? "process.offSignal" : isPrepend ? "process.prependSignal" : "process.onSignal", + args: isOff + ? [signal, cb] + : isPrepend + ? [signal, cb] + : [signal, cb, boolLit(member === "once", loc)], type: VOID, loc, }; @@ -1484,6 +1530,9 @@ export function lowerProcessMethodCall( if (isOff) { return { kind: "libCall", fn: "process.offExit", args: [cb], type: VOID, loc }; } + if (isPrepend) { + return { kind: "libCall", fn: "process.prependExit", args: [cb], type: VOID, loc }; + } return { kind: "libCall", fn: "process.onExit", args: [cb, onceArg], type: VOID, loc }; } // process.emitWarning(...): the argument vector crosses as ONE dyn diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index d3132d02f..7ba3f7191 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2216,6 +2216,54 @@ function lowerNetServerMethodCall( '"connection", "request", "checkContinue", "checkExpectation", "drop", "stream", "session", "upgrade", "connect", "error", "close", "listening", and "sessionError" are the supported server events (as literals)', ); } + if ((name === "off" || name === "removeListener") && args.length === 2) { + // server.off(event, cb): the LAST listener standing for the same + // function leaves that event's list (Node's removeListener order). + // Only the event families whose registry keeps a removable list; the + // rest refuse rather than silently leaving the listener in place. + requireStatementPosition(lowerer, call, `server.${name}(...)`); + const evT = lowerer.typeOf(args[0]!); + const event = evT.isStringLiteralType() ? evT.value : null; + const kind = + event === null + ? null + : event === "error" + ? 1 + : event === "close" + ? 2 + : event === "listening" + ? 3 + : event === "connection" || event === "secureConnection" + ? 4 + : event === "timeout" + ? 5 + : event === "drop" + ? 6 + : null; + if (kind === null) { + lowerer.noLowering( + `server.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + args[0]!, + '"error", "close", "listening", "connection", "secureConnection", "timeout", and "drop" are the events off/removeListener removes (as literals)', + ); + } + const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + const listener = lowerer.lowerExpr(args[1]!); + if (listener.type.kind !== "func") { + lowerer.unsupported( + "SC1090", + args[1]!, + `server.${name} with a listener that is not a function`, + ); + } + return { + kind: "libCall", + fn: "net.serverOff", + args: [receiver, listener, numLit(kind, loc)], + type: VOID, + loc, + }; + } if (name === "emit") { // The demux route: emit("connection", socket) hands an accepted // socket to ANOTHER server (portless's first-byte TLS peek). Only diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..53ad0b583 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -241,8 +241,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dc.tcTracePromise", "process.onUncaughtException", "process.offUncaughtException", + "process.prependUncaughtException", "process.onSignal", "process.offSignal", + "process.prependSignal", "process.onUnhandledRejection", "process.onRejectionHandled", "async.awaitDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 35f195aff..8fce72bba 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -602,6 +602,11 @@ function buildLibFnSignatures() { * alive. Invalid callbacks and uncatchable signals throw. */ "process.onSignal": { argTypes: [STRING, DYN, BOOL], result: VOID }, "process.offSignal": { argTypes: [STRING, DYN], result: VOID }, + /** process.prependListener(, cb): the same listener in front of + * the signal's existing registrations (Node appends with `on`, so the + * two differ only in dispatch order). Never `once` — that is + * prependOnceListener, which has no lowering. */ + "process.prependSignal": { argTypes: [STRING, DYN], result: VOID }, /** The process 'exit' event — process.on/once/off("exit"). args: [cb, * once bool] / [cb]. Listeners run SYNCHRONOUSLY at termination * (normal exit, process.exit(), the uncaught/unhandled exit-1 paths) @@ -610,6 +615,9 @@ function buildLibFnSignatures() { * picks the runtime adapter. Never throw. */ "process.onExit": { argTypes: [null, BOOL], result: VOID }, "process.offExit": { argTypes: [null], result: VOID }, + /** process.prependListener("exit", cb): the same listener in front of + * the exit registry's existing entries. */ + "process.prependExit": { argTypes: [null], result: VOID }, /** process.stdin listener registration — stdin.on/once("data" | "end" | * "error", cb). args: [cb, once bool]. data callbacks: () => void or * (chunk: Uint8Array) => void; end: () => void; error: () => void or @@ -1592,6 +1600,12 @@ function buildLibFnSignatures() { result: VOID, }, "net.serverOnConnection": { argTypes: [NETSERVER_T, null, BOOL], result: VOID }, + /** server.off(event, listener) / removeListener: [server, listener + * closure, list kind]. The kind selects the event's registry (1 error, + * 2 close, 3 listening, 4 connection/secureConnection, 5 timeout, + * 6 drop — the scr_net.h SCR_NET_OFF_* values); Node removes the most + * recently added matching listener. Never throws. */ + "net.serverOff": { argTypes: [NETSERVER_T, null, F64], result: VOID }, /** 'secureConnection' — the TLS server's deferred 'connection' list * (handshake-completion timing); on a server without deferred * connections (a plain net server) the registration never fires, @@ -5091,6 +5105,9 @@ function buildLibFnSignatures() { * non-function. */ /** Uncaught exception handlers and monitors: [callback dyn, once, monitor]. */ "process.onUncaughtException": { argTypes: [DYN, BOOL, BOOL], result: VOID }, + /** Uncaught handlers registered in FRONT of the registry's existing + * entries: [callback dyn, monitor] (never `once`). */ + "process.prependUncaughtException": { argTypes: [DYN, BOOL], result: VOID }, "process.offUncaughtException": { argTypes: [DYN, BOOL], result: VOID }, "process.onUnhandledRejection": { argTypes: [DYN, BOOL], result: VOID }, "process.offUnhandledRejection": { argTypes: [DYN], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..cb0207507 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -100,6 +100,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.offWarning", "process.onRejectionHandled", "process.onUncaughtException", + "process.prependUncaughtException", "process.onUnhandledRejection", "process.onWarning", "process.setDeprecationFlag", @@ -646,6 +647,8 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.offSignal", "process.onExit", "process.onSignal", + "process.prependExit", + "process.prependSignal", "process.stdio", "stdin.nextChunk", "stdin.onData", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json new file mode 100644 index 000000000..4b045387c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-process-prepend-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-process-prepend-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json new file mode 100644 index 000000000..ffd41c325 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-child-optional-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8424-child-optional-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json new file mode 100644 index 000000000..42d0a2b16 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-server-off.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8425-server-off.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 1d8febbc3..99c17eeca 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -341,7 +341,11 @@ static bool scr_rej_check_listener(ScrDyn *fn) { return false; } -static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { +/* The registration body: `prepend` puts the listener in FRONT of the + * registry's existing entries (process.prependListener — Node's + * EventEmitter order), the plain form appends. */ +static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once, + bool prepend) { if (*n == *cap) { *cap = *cap ? *cap * 2 : 4; *list = realloc(*list, *cap * sizeof **list); @@ -350,7 +354,18 @@ static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn * ScrRejState *state = calloc(1, sizeof *state); if (!state) scr_ad_oom(); state->refs = 1; - (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; + ScrRejListener entry = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; + if (prepend) { + memmove(*list + 1, *list, *n * sizeof **list); + (*list)[0] = entry; + (*n)++; + } else { + (*list)[(*n)++] = entry; + } +} + +static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { + scr_rej_push_at(list, n, cap, fn, once, false); } static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) { @@ -467,15 +482,33 @@ static void scr_uncaught_teardown(void) { scr_uncaught_sync_hook(); } +/* Arm the registry's exit-time teardown once, whichever entry point + * registers first. */ +static void scr_uncaught_arm(void) { + static SCR_TL bool armed = false; + if (armed) return; + armed = true; + scr_atexit(scr_uncaught_teardown); +} + void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor) { if (!scr_rej_check_listener(fn)) return; - static SCR_TL bool armed = false; - if (!armed) { armed = true; scr_atexit(scr_uncaught_teardown); } + scr_uncaught_arm(); size_t k = monitor ? 1 : 0; scr_rej_push(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, once); scr_uncaught_sync_hook(); } +/* process.prependListener("uncaughtException"[Monitor], fn): the same + * listener in front of the registry's existing entries. */ +void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor) { + if (!scr_rej_check_listener(fn)) return; + scr_uncaught_arm(); + size_t k = monitor ? 1 : 0; + scr_rej_push_at(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, false, true); + scr_uncaught_sync_hook(); +} + void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { if (!scr_rej_check_listener(fn)) return; size_t k = monitor ? 1 : 0; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 78b7bb0f1..9e0fb35c1 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -192,7 +192,10 @@ static bool scr_signal_check(ScrStr *name, ScrDyn *cb) { return true; } -void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { +/* The registration body both entry points share: `prepend` puts the + * listener in FRONT of the signal's existing ones (process.prependListener + * — Node's EventEmitter order), the plain form appends. */ +static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { if (!scr_signal_check(name, cb)) return; if (!scr_context_is_main()) return; #ifdef __wasi__ @@ -242,10 +245,26 @@ void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { reg->ls = realloc(reg->ls, reg->cap * sizeof *reg->ls); if (!reg->ls) scr_events_oom(); } - reg->ls[reg->n++] = (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; + ScrSigListener entry = + (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; + if (prepend) { + memmove(reg->ls + 1, reg->ls, reg->n * sizeof *reg->ls); + reg->ls[0] = entry; + reg->n++; + } else { + reg->ls[reg->n++] = entry; + } #endif } +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { + scr_signal_add(name, cb, once, false); +} + +/* process.prependListener(, cb): the same listener in front of the + * signal's existing registrations. */ +void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { scr_signal_add(name, cb, false, true); } + static void scr_sig_reg_drop(ScrSigReg *reg) { if (reg->sig > 0) { #ifdef _WIN32 @@ -1715,6 +1734,28 @@ void scr_process_off_exit_island(uint32_t island) { } #endif +/* process.prependListener("exit", cb): the same listener in front of the + * registry's existing entries. */ +void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { + if (scr_exit_ran) { + scr_closure_release(cb); + return; + } + if (scr_exit_n == scr_exit_cap) { + scr_exit_cap = scr_exit_cap ? scr_exit_cap * 2 : 2; + scr_exit_ls = realloc(scr_exit_ls, scr_exit_cap * sizeof *scr_exit_ls); + if (!scr_exit_ls) scr_events_oom(); + } + memmove(scr_exit_ls + 1, scr_exit_ls, scr_exit_n * sizeof *scr_exit_ls); + scr_exit_ls[0].cb = cb; + scr_exit_ls[0].fn = fn; + scr_exit_ls[0].once = false; +#ifdef SCR_NODE_ISLANDS + scr_exit_ls[0].island = 0; +#endif + scr_exit_n++; +} + void scr_process_off_exit(ScrClosure *cb /*borrowed*/) { for (size_t i = 0; i < scr_exit_n; i++) { #ifdef SCR_NODE_ISLANDS diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index a0025783e..3abbad8e8 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -35,8 +35,10 @@ typedef struct ScrBytes ScrBytes; /* full definition below (C11 repeat) */ void scr_events_install(void); void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once); +void scr_signal_prepend(ScrStr *name, ScrDyn *cb); void scr_signal_off(ScrStr *name, ScrDyn *cb); void scr_process_on_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double), bool once); +void scr_process_prepend_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double)); void scr_process_off_exit(ScrClosure *cb); void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index 6c5f83358..cb3bea325 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -229,6 +229,7 @@ void scr_exc_print_uncaught(void); extern SCR_TL int (*scr_uncaught_exception_hook)(bool from_promise); bool scr_exc_handle_uncaught(bool from_promise); void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor); +void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor); void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor); /* The value Node reports for an unhandled rejection (scr_async_dyn.c): an * error-like reason itself, otherwise an UnhandledPromiseRejection Error diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9347bdc9b..c17c8b004 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2438,6 +2438,30 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc scr_net_ls_add(&s->close_ls, cb, NULL, once); } +/* server.off(event, cb) / removeListener: the LAST listener standing for + * the same function leaves the event's list — Node's EventEmitter removes + * the most recently added match, so the scan runs back to front. */ +void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind) { + ScrNetLs *l = kind == SCR_NET_OFF_ERROR ? &s->err_ls + : kind == SCR_NET_OFF_CLOSE ? &s->close_ls + : kind == SCR_NET_OFF_LISTENING ? &s->listening_cbs + : kind == SCR_NET_OFF_CONNECTION ? &s->conn_ls + : kind == SCR_NET_OFF_TIMEOUT ? &s->timeout_ls + : kind == SCR_NET_OFF_DROP ? &s->drop_ls + : NULL; + if (l == NULL) return; + ScrClosure *root = cb->identity ? cb->identity : cb; + for (size_t left = l->n; left > 0; left--) { + size_t i = left - 1; + if (scr_net_listener_root(&l->ls[i]) != root) continue; + scr_closure_release(l->ls[i].cb); + scr_net_once_release(l->ls[i].shared_once); + memmove(&l->ls[i], &l->ls[i + 1], (l->n - i - 1) * sizeof *l->ls); + l->n--; + return; + } +} + /* server.on('listening', cb): joins the deferred 'listening' list the * listen(port, cb) callback rides (the sweep fires it once after a * successful bind — Node's next-tick emit). A registration AFTER the diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index af4b8e1b8..f22943717 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -199,6 +199,18 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc * cb) rides — fires once per successful bind; late registrations on an * already-listening server never fire (Node's once-per-listen emit). */ void scr_net_server_on_listening(ScrNetServer *s, ScrClosure *cb /*moves*/, bool once); +/* server.off(event, cb) / removeListener: the LAST listener standing for + * the same function leaves that event's list (Node's EventEmitter + * removal order). `kind` selects the list (SCR_NET_OFF_*). */ +enum { + SCR_NET_OFF_ERROR = 1, + SCR_NET_OFF_CLOSE = 2, + SCR_NET_OFF_LISTENING = 3, + SCR_NET_OFF_CONNECTION = 4, + SCR_NET_OFF_TIMEOUT = 5, + SCR_NET_OFF_DROP = 6, +}; +void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind); void scr_net_server_on_connection(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrNetConnFn fn, bool once); /* 'secureConnection' — the deferred-connection list (TLS handshake * timing); released unread on a server without deferral (a plain net diff --git a/tests/corpus/8423-process-prepend-listeners.ts b/tests/corpus/8423-process-prepend-listeners.ts new file mode 100644 index 000000000..b12d4d336 --- /dev/null +++ b/tests/corpus/8423-process-prepend-listeners.ts @@ -0,0 +1,24 @@ +// process.prependListener registers the listener at the FRONT of an +// event's listener list (Node's EventEmitter order) for the registries +// that own a prepend entry: 'exit' and the signal family. on/once keep +// appending, so a prepend registered between them still dispatches first. +import { spawn } from "node:child_process"; + +process.on("exit", (code) => console.log(`exit on 1 (${code})`)); +process.prependListener("exit", (code) => console.log(`exit prepend 0 (${code})`)); +process.once("exit", (code) => console.log(`exit once 2 (${code})`)); +process.prependListener("exit", () => console.log("exit prepend last-registered, still first")); + +let ints = 0; +const onInt = () => { + ints++; + console.log("SIGINT on", ints); +}; +process.on("SIGINT", onInt); +process.prependListener("SIGINT", () => console.log("SIGINT prepend", ints)); + +const child = spawn("/bin/sh", ["-c", "sleep 0.2; kill -INT $PPID; sleep 0.2"], { stdio: "ignore" }); +child.on("exit", (code) => { + console.log("child exit", code ?? -1, "ints", ints); +}); +console.log("main done"); diff --git a/tests/corpus/8424-child-optional-chain.ts b/tests/corpus/8424-child-optional-chain.ts new file mode 100644 index 000000000..59581df88 --- /dev/null +++ b/tests/corpus/8424-child-optional-chain.ts @@ -0,0 +1,27 @@ +// The optional-chain form of the ChildProcess members: `this.child?.kill()` +// and `this.child?.on(...)` reach the same lowerings as the plain calls, so +// an absent child is a no-op and a present one keeps Node's shapes. +import { spawn, type ChildProcess } from "node:child_process"; + +class Runner { + child: ChildProcess | undefined; + constructor(child: ChildProcess | undefined) { + this.child = child; + } + kill(): void { + this.child?.kill("SIGTERM"); + console.log("kill attempted, child present:", this.child !== undefined); + } + watch(): void { + this.child?.on("exit", (code) => console.log("exit", code)); + } +} + +const withChild = new Runner(spawn("sleep", ["5"], { stdio: "ignore" })); +withChild.watch(); +withChild.kill(); + +const withoutChild = new Runner(undefined); +withoutChild.watch(); +withoutChild.kill(); +console.log("done"); diff --git a/tests/corpus/8425-server-off.ts b/tests/corpus/8425-server-off.ts new file mode 100644 index 000000000..f53a05c5a --- /dev/null +++ b/tests/corpus/8425-server-off.ts @@ -0,0 +1,27 @@ +// server.off / removeListener: the LAST registered listener for the same +// function leaves that event's list (Node's removeListener order), the +// remaining listeners still run, and removing an unregistered function is +// a no-op. The EADDRINUSE from a second listen on a bound port is the real +// event here. +import * as net from "node:net"; + +const first = net.createServer(); +first.listen(0, "127.0.0.1", () => { + const port = first.address().port; + const second = net.createServer(); + const removed = (err: Error) => console.log("removed listener ran", (err as NodeJS.ErrnoException).code); + const kept = (err: Error) => console.log("kept listener ran", (err as NodeJS.ErrnoException).code); + second.on("error", removed); + second.on("error", kept); + second.off("error", removed); + second.off("error", () => console.log("never registered")); + second.listen(port, "127.0.0.1", () => { + console.log("unexpected: second listen succeeded"); + second.close(); + }); + setTimeout(() => { + console.log("closing"); + second.close(); + first.close(() => console.log("first closed")); + }, 20); +}); From dda9f585e02e42b5478bfc1d0b3d71ffa93d519a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:37:40 -0700 Subject: [PATCH 05/15] Expose the process streams' fd and stdin's raw-mode flag process.stdin/stdout/stderr.fd answers Node's compile-time descriptor (0/1/2, on a TTY, a pipe, and a redirected file alike), and process.stdin.isRaw answers the TTY's raw-mode flag through scr_process_stdin_is_raw (1/0), or Node's undefined on a pipe-backed stdin through the module's interned `boolean | undefined` union. The declared type of isRaw is `boolean` (as @types/node has it), so the read is added to isOptionalProcessStreamProperty: without that entry maybeNarrow bridges the union to the declared arm and the runtime guard traps on the undefined a pipe actually answers. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 8 ++++ .../compiler/src/backend/llvm/lib-process.ts | 40 ++++++++++++++----- .../src/frontend/lowering/builtins/process.ts | 35 +++++++++++++--- .../lowering/expressions/narrowing.ts | 2 +- .../compiler/src/ir/builtin-signatures.ts | 4 ++ packages/compiler/src/ir/runtime-features.ts | 1 + packages/compiler/src/ir/validate.ts | 14 +++++++ .../8426-process-stream-fd-israw.ts.json | 6 +++ packages/runtime/src/scr_events.c | 9 +++++ packages/runtime/src/scr_events.h | 1 + tests/corpus/8426-process-stream-fd-israw.ts | 11 +++++ 11 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json create mode 100644 tests/corpus/8426-process-stream-fd-israw.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index e406cece3..1480b4ee2 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -340,6 +340,12 @@ declare var process: { * (chunks stay bytes; decode with TextDecoder). */ stdin: { readonly isTTY: boolean; + /* The descriptor the stream is built over (Node reports 0 here and + * 1/2 on stdout/stderr, whatever the underlying file is). */ + readonly fd: 0; + /* True while a TTY stdin runs in raw mode; Node's own property is + * undefined on a non-TTY stdin (the `x || false` idiom). */ + readonly isRaw: boolean; on(event: "data", listener: (chunk: Uint8Array) => void): void; on(event: "end", listener: () => void): void; on(event: "error", listener: (err: Error) => void): void; @@ -1753,6 +1759,8 @@ declare module "tty" { /* The bytes queued behind the last write: the compiled binary's fd * 1/2 writes are synchronous, so it is always 0. */ readonly writableLength: number; + /* The descriptor behind the stream (1 for stdout, 2 for stderr). */ + readonly fd: number; getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index a33bf9cb9..f284febd8 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.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 { F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js"; +import { BOOL, F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { mockTimerSym } from "./lib-shared.js"; @@ -699,17 +699,26 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll B.line(`call void @scr_process_on_exit(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); return { name: "", type: e.type }; } - if (e.fn === "process.envGet" || e.fn === "process.columns" || e.fn === "process.rows") { - // getenv(3) / ioctl(TIOCGWINSZ): the runtime answers a +1 string or - // NULL (a width or a negative sentinel); the union construction is - // type-directed HERE — present wraps the value arm, absent yields + if ( + e.fn === "process.envGet" || + e.fn === "process.columns" || + e.fn === "process.rows" || + e.fn === "process.stdinIsRaw" + ) { + // getenv(3) / ioctl(TIOCGWINSZ) / termios: the runtime answers a +1 + // string or NULL, or a number with a negative sentinel (the raw-mode + // flag rides the same double channel, 1/0/-1); the union construction + // is type-directed HERE — present wraps the value arm, absent yields // the interned immortal undefined-arm instance. 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 undefTag = undefinedArmTag(e.type, host.unionsById); const isEnv = e.fn === "process.envGet"; - const valTag = def ? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : "f64")) : -1; + const isRaw = e.fn === "process.stdinIsRaw"; + const valTag = def + ? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : isRaw ? "bool" : "f64")) + : -1; if (valTag < 0 || undefTag < 0) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); const args = e.args.map((a) => host.emitExpr(a)); @@ -724,6 +733,10 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.declare(`declare ptr @scr_env_get(ptr)`); B.line(`${raw} = call ptr @scr_env_get(ptr ${args[0]!.name})`); B.line(`${present} = icmp ne ptr ${raw}, null`); + } else if (isRaw) { + host.declare(`declare double @scr_process_stdin_is_raw()`); + B.line(`${raw} = call double @scr_process_stdin_is_raw()`); + B.line(`${present} = fcmp oge double ${raw}, ${f64Lit(0)}`); } else { const runtimeFn = e.fn === "process.rows" ? "scr_process_rows" : "scr_process_columns"; host.declare(`declare double @${runtimeFn}(double)`); @@ -732,9 +745,18 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll } B.condBr(present, lp, la); B.startBlock(lp); - B.line( - `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`, - ); + if (isRaw) { + // The raw flag: 1/0 becomes the bool arm's i1. + const flag = B.tmp(); + B.line(`${flag} = fcmp one double ${raw}, ${f64Lit(0)}`); + B.line( + `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: flag, type: BOOL })}, ptr ${slot}`, + ); + } else { + B.line( + `store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`, + ); + } B.br(lj); B.startBlock(la); B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index bd51e72e3..0d4268384 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -247,12 +247,13 @@ export function lowerProcStreamMethodCall( } /** Standard streams expose optional terminal properties: pipes have no - * isTTY, columns, or rows. The declarations do not prove their presence. */ + * isTTY, columns, or rows, and only a TTY stdin has a raw-mode flag. The + * declarations do not prove their presence. */ export function isOptionalProcessStreamProperty( lowerer: Lowerer, expr: ts.PropertyAccessExpression, ): boolean { - if (!["isTTY", "columns", "rows"].includes(expr.name.text)) return false; + if (!["isTTY", "columns", "rows", "isRaw"].includes(expr.name.text)) return false; let recv = expr.expression; while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) recv = recv.expression; @@ -261,7 +262,7 @@ export function isOptionalProcessStreamProperty( if ( stream === "stdout" || stream === "stderr" || - (stream === "stdin" && expr.name.text === "isTTY") + (stream === "stdin" && (expr.name.text === "isTTY" || expr.name.text === "isRaw")) ) return true; } @@ -357,7 +358,9 @@ export function lowerProcessStreamProperty( member !== "isTTY" && member !== "columns" && member !== "rows" && - member !== "writableLength" + member !== "writableLength" && + member !== "fd" && + member !== "isRaw" ) return null; let recv: ts.Expression = expr.expression; @@ -449,7 +452,29 @@ export function lowerProcessStreamProperty( loc, }; } - if (stream === "stdin") return null; + if (member === "fd") { + // The process streams are built over fd 0/1/2 (Node answers those on + // a TTY, a pipe, and a redirected file alike). + return { + kind: "numLit", + value: stream === "stdin" ? 0 : stream === "stdout" ? 1 : 2, + type: F64, + loc, + }; + } + if (stream === "stdin") { + if (member !== "isRaw") return null; + // Node answers the raw-mode flag of a TTY stdin and undefined on + // anything else (a pipe-backed stdin); the runtime sentinel carries + // both (scr_process_stdin_is_raw). + return { + kind: "libCall", + fn: "process.stdinIsRaw", + args: [], + type: lowerer.withUndefinedArm(BOOL), + loc, + }; + } const want = lowerer.withUndefinedArm(F64); return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 6a6a25292..65c19c256 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -589,7 +589,7 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx if ( isJsSourceFile(ts.getSourceFileOfNode(node)) && ts.isPropertyAccessExpression(node) && - ["isTTY", "columns", "rows"].includes(node.name.text) && + ["isTTY", "columns", "rows", "isRaw"].includes(node.name.text) && lowerer.isStdlibMember(node) ) return expr; diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 8fce72bba..8821c13a1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5367,6 +5367,10 @@ function buildLibFnSignatures() { "process.columns": { argTypes: [F64], result: VOID }, /** Terminal height, with the same fd and optional-number contract. */ "process.rows": { argTypes: [F64], result: VOID }, + /** process.stdin.isRaw: the raw-mode flag of a TTY stdin as the + * module's interned `boolean | undefined` union (a pipe-backed stdin + * has no such flag — Node answers undefined there). Never throws. */ + "process.stdinIsRaw": { argTypes: [], result: VOID }, /** process.stdin.destroy(): a deliberate no-op (no stream machinery * exists to tear down; documented in SEMANTICS.md). */ "process.stdinDestroy": { argTypes: [], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index cb0207507..2c9f6d57f 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -650,6 +650,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.prependExit", "process.prependSignal", "process.stdio", + "process.stdinIsRaw", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..cd4fc1e44 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4472,6 +4472,20 @@ function validateFunction( } return true; } + if (e.fn === "process.stdinIsRaw") { + // Result is the module's interned `boolean | undefined` union + // (Node's undefined on a pipe-backed stdin). + const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; + const ok = + def && + def.arms.length === 2 && + def.arms[0]!.kind === "bool" && + def.arms[1]!.kind === "undefinedT"; + if (!ok) { + err(`libCall process.stdinIsRaw must return the 'boolean | undefined' union`, e.loc); + } + return true; + } return false; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json new file mode 100644 index 000000000..814d16007 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-process-stream-fd-israw.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8426-process-stream-fd-israw.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 9e0fb35c1..a159c2fdc 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1734,6 +1734,15 @@ void scr_process_off_exit_island(uint32_t island) { } #endif +/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a + * pipe-backed stdin has no such flag and answers Node's undefined, which + * the emitter's union construction spells -1. */ +double scr_process_stdin_is_raw(void) { + if (!scr_context_is_main()) return -1; + if (!scr_process_is_tty(0)) return -1; + return scr_stdio_raw ? 1.0 : 0.0; +} + /* process.prependListener("exit", cb): the same listener in front of the * registry's existing entries. */ void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index 3abbad8e8..5e99aa607 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -39,6 +39,7 @@ void scr_signal_prepend(ScrStr *name, ScrDyn *cb); void scr_signal_off(ScrStr *name, ScrDyn *cb); void scr_process_on_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double), bool once); void scr_process_prepend_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double)); +double scr_process_stdin_is_raw(void); void scr_process_off_exit(ScrClosure *cb); void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); diff --git a/tests/corpus/8426-process-stream-fd-israw.ts b/tests/corpus/8426-process-stream-fd-israw.ts new file mode 100644 index 000000000..073f4c48d --- /dev/null +++ b/tests/corpus/8426-process-stream-fd-israw.ts @@ -0,0 +1,11 @@ +// The process streams' descriptors and stdin's raw-mode flag: fd is the +// compile-time 0/1/2 Node reports for a TTY, a pipe, and a redirected file +// alike; isRaw is the TTY's raw-mode flag and Node's undefined when stdin +// is not a terminal (the harness's own pipe). +import { writeSync } from "node:fs"; + +console.log("stdin fd", process.stdin.fd); +console.log("stdout fd", process.stdout.fd); +console.log("stderr fd", process.stderr.fd); +console.log("stdin isRaw", process.stdin.isRaw, typeof process.stdin.isRaw); +writeSync(process.stdout.fd, "writeSync through process.stdout.fd\n"); From 19dbebda675884096a964af7862bf0e30828d84d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:22 -0700 Subject: [PATCH 06/15] Adopt an EventEmitter's type argument as the typing aid it is `class Sink extends EventEmitter` now compiles: the type argument of an EventEmitter base is the listener MAP, a pure typing aid (Node erases it too), so the base is the same emitter class whatever the map says. A stream base's argument IS the element layout and still refuses, as does every other generic base. The bundled fallback `events` module declares the class with an unconstrained map so interfaces typecheck against it, and its members stay the untyped `...args: any[]` surface either way. Co-Authored-By: Claude Opus 5.5 --- .../ambient/node-fallback/events.d.ts | 7 ++++- .../src/frontend/lowering/lower-classes.ts | 17 ++++++++-- .../corpus/8427-emitter-generic-base.ts.json | 6 ++++ tests/corpus/8427-emitter-generic-base.ts | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json create mode 100644 tests/corpus/8427-emitter-generic-base.ts diff --git a/packages/compiler/ambient/node-fallback/events.d.ts b/packages/compiler/ambient/node-fallback/events.d.ts index dda1cd086..ca55ca0c8 100644 --- a/packages/compiler/ambient/node-fallback/events.d.ts +++ b/packages/compiler/ambient/node-fallback/events.d.ts @@ -5,7 +5,12 @@ * checks annotated listener parameters against it (unannotated non-empty * parameter lists have no static types and fence at the registration). */ declare module "events" { - class EventEmitter { + /* Generic like @types/node's class: the type argument is the listener + * MAP, a pure typing aid with no runtime layout (Node erases it too), so + * `class Sink extends EventEmitter` adopts the same emitter base. + * The map stays unconstrained here — the members below are the untyped + * `...args: any[]` surface either way. */ + class EventEmitter> { constructor(); static defaultMaxListeners: number; /* Listeners registered under this symbol observe 'error' emits first diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..b06823299 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2106,7 +2106,14 @@ export function collectClassShapeInner( const emitterBase = lowerer.builtinEmitterInfoOf(resolved); const streamBaseNs = builtinStreamInfoOf(lowerer, resolved); if (emitterBase || streamBaseNs) { - if (t.typeArguments?.nodes) lowerer.unsupported("SC1090", t, "extending generic classes"); + // An EventEmitter's type argument is the listener MAP, a pure + // typing aid: the emitter base is one runtime class whatever the + // map says, and the map's keys never reach the layout (Node's + // own generic erases at runtime), so `extends + // EventEmitter` adopts the same base. A stream base's + // argument IS the element layout, which still has to be concrete. + if (!emitterBase && t.typeArguments?.nodes) + lowerer.unsupported("SC1090", t, "extending generic classes"); base = (emitterBase ?? streamBaseNs)!; continue; } @@ -2336,7 +2343,13 @@ export function collectClassShapeInner( base = instBase; continue; } - if (t.typeArguments?.nodes) lowerer.unsupported("SC1090", t, "extending generic classes"); + // An EventEmitter base's type argument is the listener MAP — a pure + // typing aid (the runtime base is one emitter class whatever the map + // says, and Node erases it too), so `extends EventEmitter` + // adopts the same base. Every other generic base still refuses: + // its argument can carry a layout. + if (t.typeArguments?.nodes && lowerer.builtinEmitterInfoOf(symbol) === null) + lowerer.unsupported("SC1090", t, "extending generic classes"); if (named?.extendsArray) lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json new file mode 100644 index 000000000..5ebf8b665 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-emitter-generic-base.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8427-emitter-generic-base.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8427-emitter-generic-base.ts b/tests/corpus/8427-emitter-generic-base.ts new file mode 100644 index 000000000..7ffc03f55 --- /dev/null +++ b/tests/corpus/8427-emitter-generic-base.ts @@ -0,0 +1,31 @@ +// A class extending EventEmitter: the type argument is the +// listener MAP — a pure typing aid with no runtime layout of its own, so +// the emitted class adopts the same emitter base (Node erases it too). +// on/emit dispatch keeps Node's order, and the subclass's own members +// stay alongside the inherited ones. +import { EventEmitter } from "node:events"; + +interface BufferEvents { + data: [chunk: string]; + end: []; +} + +class StdinBuffer extends EventEmitter { + private chunks: string[] = []; + push(chunk: string): void { + this.chunks.push(chunk); + this.emit("data", chunk); + } + finish(): void { + console.log("joined", this.chunks.join("")); + this.emit("end"); + } +} + +const buffer = new StdinBuffer(); +buffer.on("data", (chunk) => console.log("data", chunk)); +buffer.on("end", () => console.log("end")); +buffer.push("a"); +buffer.push("b"); +buffer.finish(); +console.log("listeners", buffer.listenerCount("data")); From b8b4a4251a555ecff8aa782140cce416203dc43b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:33 -0700 Subject: [PATCH 07/15] Take spawn's detached option as a runtime boolean `spawn(cmd, args, { detached: process.platform !== "win32" })`, the portable daemon idiom, no longer refuses: a non-literal detached value lowers into scr_spawn_opts's bool slot, the same POSIX_SPAWN_SETSID flag the literal forms reach, so the child leads its own process group. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/child-process.ts | 10 +++--- .../8428-spawn-detached-runtime.ts.json | 6 ++++ tests/corpus/8428-spawn-detached-runtime.ts | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json create mode 100644 tests/corpus/8428-spawn-detached-runtime.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process.ts b/packages/compiler/src/frontend/lowering/builtins/child-process.ts index 9096316a3..b82984326 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process.ts @@ -477,11 +477,11 @@ export function lowerSpawnCall(lowerer: Lowerer, expr: ts.CallExpression, loc: S } else if (m.value.kind === ts.SyntaxKind.FalseKeyword) { detached = boolLit(false, loc); } else { - lowerer.noLowering( - "spawn with a non-literal detached option", - m.value, - "detached must be a boolean literal", - ); + // A runtime boolean — the portable daemon idiom `detached: + // process.platform !== "win32"` — reaches the same + // POSIX_SPAWN_SETSID flag through scr_spawn_opts's bool slot. + detached = lowerer.lowerExprExpecting(m.value, BOOL); + plain = false; } break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json new file mode 100644 index 000000000..5ffd8c9bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-spawn-detached-runtime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8428-spawn-detached-runtime.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8428-spawn-detached-runtime.ts b/tests/corpus/8428-spawn-detached-runtime.ts new file mode 100644 index 000000000..a16f24c3e --- /dev/null +++ b/tests/corpus/8428-spawn-detached-runtime.ts @@ -0,0 +1,36 @@ +// spawn's `detached` option as a RUNTIME boolean — the portable daemon +// idiom `detached: process.platform !== "win32"` — reaches the same +// POSIX_SPAWN_SETSID flag as the literal form: a detached child leads its +// own process group (pgid == pid), a non-detached one stays in the +// parent's group. +import { spawn } from "node:child_process"; + +const PGID = "ps -o pgid= -p $$ | tr -d ' '"; +const mode = process.platform !== "win32"; + +const lead = spawn("/bin/sh", ["-c", PGID], { detached: mode, stdio: ["ignore", "pipe", "ignore"] }); +let leadOut = ""; +if (lead.stdout) { + lead.stdout.setEncoding("utf8"); + lead.stdout.on("data", (chunk: string) => { + leadOut += chunk; + }); +} +lead.on("close", () => { + console.log("detached leads its own group:", leadOut.trim() === String(lead.pid)); + + const joined = spawn("/bin/sh", ["-c", PGID], { + detached: false, + stdio: ["ignore", "pipe", "ignore"], + }); + let joinedOut = ""; + if (joined.stdout) { + joined.stdout.setEncoding("utf8"); + joined.stdout.on("data", (chunk: string) => { + joinedOut += chunk; + }); + } + joined.on("close", () => { + console.log("non-detached stays behind:", joinedOut.trim() !== String(joined.pid)); + }); +}); From e59c117e3bf8017bd24d69a2df1b259bda01692a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:38:57 -0700 Subject: [PATCH 08/15] Record CI costs for the new process and child-process corpus programs The six new differential programs' plain and sanitized measurements, in tests/harness/corpus-costs/.json so the shard balancer weighs them without touching the shared table. Co-Authored-By: Claude Opus 5.5 --- .../harness/corpus-costs/8423-process-prepend-listeners.ts.json | 1 + tests/harness/corpus-costs/8424-child-optional-chain.ts.json | 1 + tests/harness/corpus-costs/8425-server-off.ts.json | 1 + tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json | 1 + tests/harness/corpus-costs/8427-emitter-generic-base.ts.json | 1 + tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json | 1 + 6 files changed, 6 insertions(+) create mode 100644 tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json create mode 100644 tests/harness/corpus-costs/8424-child-optional-chain.ts.json create mode 100644 tests/harness/corpus-costs/8425-server-off.ts.json create mode 100644 tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json create mode 100644 tests/harness/corpus-costs/8427-emitter-generic-base.ts.json create mode 100644 tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json diff --git a/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json b/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json new file mode 100644 index 000000000..f315263c8 --- /dev/null +++ b/tests/harness/corpus-costs/8423-process-prepend-listeners.ts.json @@ -0,0 +1 @@ +{ "plain": 2850, "san": 21650 } diff --git a/tests/harness/corpus-costs/8424-child-optional-chain.ts.json b/tests/harness/corpus-costs/8424-child-optional-chain.ts.json new file mode 100644 index 000000000..0f324b400 --- /dev/null +++ b/tests/harness/corpus-costs/8424-child-optional-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 2300, "san": 4100 } diff --git a/tests/harness/corpus-costs/8425-server-off.ts.json b/tests/harness/corpus-costs/8425-server-off.ts.json new file mode 100644 index 000000000..4c1984f33 --- /dev/null +++ b/tests/harness/corpus-costs/8425-server-off.ts.json @@ -0,0 +1 @@ +{ "plain": 2350, "san": 5950 } diff --git a/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json b/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json new file mode 100644 index 000000000..1e5609a66 --- /dev/null +++ b/tests/harness/corpus-costs/8426-process-stream-fd-israw.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2600 } diff --git a/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json b/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json new file mode 100644 index 000000000..f8c3504c6 --- /dev/null +++ b/tests/harness/corpus-costs/8427-emitter-generic-base.ts.json @@ -0,0 +1 @@ +{ "plain": 2450, "san": 5700 } diff --git a/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json b/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json new file mode 100644 index 000000000..2447b70e5 --- /dev/null +++ b/tests/harness/corpus-costs/8428-spawn-detached-runtime.ts.json @@ -0,0 +1 @@ +{ "plain": 4250, "san": 2850 } From 1a3caa88eb781d64a163d012712dfb16e5c35c7b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:20:36 -0700 Subject: [PATCH 09/15] web/text singletons: URLSearchParams ToString, match reads, Array.from URLSearchParams crossing an implicit ToString (`${sp}`, "" + sp, String(sp)) had no lowering: ensureString now answers sp.toString, the serialized pair list. Record inits also accept shorthand members (`new URLSearchParams({ model })`) like the explicit `{ model: model }` spelling. `re.exec(s)?.index` keeps the optional chain: the read is the match row's index/input member (the chain lowering binds the guarded receiver first), so the questionDotToken guard no longer diverts it. Array.from(s) over a string whose binding the checker narrowed through a guard read as the narrowed union and fenced; the site type now decides, and the read is coerced to that string (code points, fresh array). Corpus 8423-8425 pin all three against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/url.ts | 21 ++++++++++++++++++- .../lowering/containers/array-construction.ts | 13 ++++++++++++ .../frontend/lowering/expressions/binary.ts | 4 ++++ .../lowering/expressions/property-access.ts | 4 +++- ...23-urlsearchparams-implicit-string.ts.json | 6 ++++++ .../8424-regex-match-index-chain.ts.json | 6 ++++++ .../8425-array-from-string-nullable.ts.json | 6 ++++++ .../8423-urlsearchparams-implicit-string.ts | 15 +++++++++++++ tests/corpus/8424-regex-match-index-chain.ts | 12 +++++++++++ .../corpus/8425-array-from-string-nullable.ts | 19 +++++++++++++++++ ...23-urlsearchparams-implicit-string.ts.json | 1 + .../8424-regex-match-index-chain.ts.json | 1 + .../8425-array-from-string-nullable.ts.json | 1 + 13 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json create mode 100644 tests/corpus/8423-urlsearchparams-implicit-string.ts create mode 100644 tests/corpus/8424-regex-match-index-chain.ts create mode 100644 tests/corpus/8425-array-from-string-nullable.ts create mode 100644 tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json create mode 100644 tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json create mode 100644 tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/url.ts b/packages/compiler/src/frontend/lowering/builtins/url.ts index 8619b4997..1c4736cd7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/url.ts +++ b/packages/compiler/src/frontend/lowering/builtins/url.ts @@ -228,11 +228,30 @@ function lowerSearchParamsInit(lowerer: Lowerer, arg: ts.Expression, loc: SrcLoc if (ts.isObjectLiteralExpression(arg)) { let acc: IrExpr = { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc }; for (const prop of arg.properties.nodes) { + // `{ model }` appends the binding's value under its own name, like + // the `{ model: model }` spelling. + if (ts.isShorthandPropertyAssignment(prop)) { + const keyExpr: IrExpr = { + kind: "strLit", + value: (prop.name as ts.Identifier | ts.StringLiteral).text, + type: STRING, + loc: locOf(prop.name), + }; + const value = lowerer.coerceInto(prop.name, lowerer.lowerShorthandValue(prop), STRING); + acc = { + kind: "libCall", + fn: "sp.with", + args: [acc, keyExpr, value], + type: SEARCH_PARAMS_T, + loc, + }; + continue; + } if (!ts.isPropertyAssignment(prop) || prop.name === undefined) { lowerer.unsupported( "SC1090", prop, - "URLSearchParams record inits with spreads, accessors, or shorthand entries", + "URLSearchParams record inits with spreads or accessors", ); } let key: string; diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2a3ad64c0..4d507b3a6 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1085,6 +1085,19 @@ export function lowerArrayFromCall( // and materializes holes while keeping element reference identity. if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) { const source = lowerer.lowerExpr(args[0]!); + // Array.from(s): the string iterator's code points, into a fresh + // array (scr_str_code_points already builds one, like Array.from). + // The checker can narrow the argument to a string while the binding + // keeps its nullable storage (`value.includes(x) && Array.from(value)`), + // so the site type decides and the read is coerced to that string. + if ( + args.length === 1 && + (source.type.kind === "string" || + lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string") + ) { + const str = source.type.kind === "string" ? source : lowerer.coerceToExpected(source, STRING); + return strCharsCall(str, loc); + } let src = source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source; if (src.type.kind === "array") { diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..17c88c3c0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -648,6 +648,10 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr if (e.type.kind === "string") return e; if (e.type.kind === "url") return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc }; + // String(sp) / `${new URLSearchParams(...)}`: URLSearchParams.toString, + // the serialized list (`?` stripped, pairs encoded). + if (e.type.kind === "searchParams") + return { kind: "libCall", fn: "sp.toString", args: [e], type: STRING, loc: e.loc }; // String(date) / `${date}` / "" + date: Date's ToPrimitive prefers // toString(). if (e.type.kind === "date") diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..c6fd215ea 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -698,7 +698,9 @@ export function lowerPropertyAccessExpr( }; } } - if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) { + // `re.exec(s)?.index` takes the same read: the chain lowering binds the + // guarded receiver (non-nullish inside the guard) before re-dispatching. + if (expr.name.text === "index" || expr.name.text === "input") { const represented = tryLowerExpression(lowerer, expr.expression); if ( represented && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..511710dcf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-urlsearchparams-implicit-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..46f66548d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8424-regex-match-index-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..24a32bab3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8425-array-from-string-nullable.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8423-urlsearchparams-implicit-string.ts b/tests/corpus/8423-urlsearchparams-implicit-string.ts new file mode 100644 index 000000000..da32d4906 --- /dev/null +++ b/tests/corpus/8423-urlsearchparams-implicit-string.ts @@ -0,0 +1,15 @@ +// URLSearchParams crossing an implicit ToString: a template span, string +// concatenation, String() and an explicit toString() all serialize the +// pair list in insertion order, percent-encoded exactly as Node does. The +// record init accepts shorthand members (`{ model }`) like `{ model: model }`. +const model = "m 1"; +const sp = new URLSearchParams({ model, autoload: "false", q: "a&b" }); +console.log(`${sp}`); +console.log("prefix:" + sp); +console.log(String(sp)); +console.log(sp.toString()); +console.log(sp.get("model"), sp.get("q"), sp.get("missing")); +const empty = new URLSearchParams({}); +console.log(`[${empty}]`, JSON.stringify(empty.toString())); +const pairs = [...sp]; +console.log(pairs.length, pairs[0]![0], pairs[0]![1]); diff --git a/tests/corpus/8424-regex-match-index-chain.ts b/tests/corpus/8424-regex-match-index-chain.ts new file mode 100644 index 000000000..4245b161d --- /dev/null +++ b/tests/corpus/8424-regex-match-index-chain.ts @@ -0,0 +1,12 @@ +// A match row read through an optional chain: `re.exec(s)?.index` and the +// `?.input` beside it take the same member reads as the guarded form. +const re = /b(c)/g; +const text = "abcz"; +const m = re.exec(text); +console.log(m !== null ? m.index : -1); +console.log(re.exec(text)?.index); +console.log(re.exec(text)?.input); +console.log(/(z)/.exec(text)?.index); +console.log(/q/.exec(text)?.index); +const groups = /(?b)/.exec(text)?.groups; +console.log(groups?.lead); diff --git a/tests/corpus/8425-array-from-string-nullable.ts b/tests/corpus/8425-array-from-string-nullable.ts new file mode 100644 index 000000000..210eb1b2a --- /dev/null +++ b/tests/corpus/8425-array-from-string-nullable.ts @@ -0,0 +1,19 @@ +// Array.from over a string the checker narrowed through a guard, and over +// code points (surrogate pairs stay whole), each a fresh array. +const values: (string | undefined)[] = ["abc", undefined, "a\u{1F600}b"]; +for (const value of values) { + if (value === undefined) { + console.log("skip"); + continue; + } + const chars = Array.from(value); + console.log(chars.length, chars.join("|")); +} +const maybe: string | undefined = "xy"; +if (maybe !== undefined) console.log(Array.from(maybe).length); +const plain: string = "q"; +console.log(Array.from(plain), Array.from(plain).length); +function guarded(v: string | undefined): boolean { + return v !== undefined && Array.from(v).length > 1; +} +console.log(guarded("ab"), guarded("a"), guarded(undefined)); diff --git a/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..846fa300d --- /dev/null +++ b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1 @@ +{ "plain": 4163, "san": 7431 } diff --git a/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..2520fba26 --- /dev/null +++ b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 2579, "san": 9345 } diff --git a/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..b7845d83e --- /dev/null +++ b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json @@ -0,0 +1 @@ +{ "plain": 2724, "san": 5297 } From 4f7c48b4e311356c6b51181dfd8a09b84a054767 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:20:44 -0700 Subject: [PATCH 10/15] reflection: Reflect.has, and Object reflection over records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect.has had no lowering. It is [[HasProperty]] — own and inherited names (Array.prototype's, Object.prototype's, a class's compiled methods), proxy `has` traps, symbol and property-key conversion — which is exactly the `in` operator's checked-dynamic probe, so it lowers to dyn.hasKeyComputed over the target's dyn view (and the target is fenced when it has no such view). A primitive target is Node's TypeError, which the probe answers false for instead: fenced by name, never silently divergent. Object.getOwnPropertyNames/Symbols/Descriptors only accepted values that already were checked-dynamic; a record, array or class instance now boxes into its own dyn view (own fields and elements, in property order) like the function and error targets already did. Corpus 8426 pins both against Node. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/builtins/reflection.ts | 34 +++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 4 +++ .../8426-reflect-object-reflections.ts.json | 6 ++++ .../corpus/8426-reflect-object-reflections.ts | 28 +++++++++++++++ .../8426-reflect-object-reflections.ts.json | 1 + 5 files changed, 73 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json create mode 100644 tests/corpus/8426-reflect-object-reflections.ts create mode 100644 tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/reflection.ts b/packages/compiler/src/frontend/lowering/builtins/reflection.ts index ef55fa7e5..2988f9feb 100644 --- a/packages/compiler/src/frontend/lowering/builtins/reflection.ts +++ b/packages/compiler/src/frontend/lowering/builtins/reflection.ts @@ -62,6 +62,40 @@ export function lowerReflectCall( } if (call.questionDotToken) return null; const reflectMember = lowerer.stdlibGlobalMember(access, "Reflect"); + // Reflect.has(target, key) — the `in` operator's presence query over + // the target's checked-dynamic view (own and inherited names, proxy + // `has` traps included). A primitive target is Node's TypeError + // ("Reflect.has called on non-object"), which the presence query + // answers false for instead: fenced, never silently divergent. + if ( + reflectMember === "has" && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const loc = locOf(call); + const target = lowerer.lowerExpr(call.arguments.nodes[0]!); + const primitive = + isUnitType(target.type) || + ["bool", "f64", "string", "bigint", "symbol"].includes(target.type.kind); + if (primitive) + lowerer.noLowering( + "Reflect.has on a primitive target", + call.arguments.nodes[0]!, + "Node throws a TypeError for a primitive target; narrow to an object first", + ); + if (!lowerer.dynConvertible(target.type)) + lowerer.noLowering("Reflect.has on this target", call.arguments.nodes[0]!); + return { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [ + lowerer.coerceToExpected(target, DYN), + lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN), + ], + type: BOOL, + loc, + }; + } // Reflect.getOwnPropertyDescriptor and Reflect.defineProperty over // checked objects (execa's Promise mixin): the Object operations, with // defineProperty answering true. A non-object target throws the Object diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 955b57610..fa7afada4 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7885,6 +7885,10 @@ function lowerObjectStaticCall( isUnitType(object.type) ) object = { kind: "dynFrom", value: object, type: DYN, loc: locOf(call) }; + // A record, array or class instance reflects through its own + // checked-dynamic view (own fields and elements, in property order). + if (object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) + object = lowerer.coerceToExpected(object, DYN); if (object.type.kind === "dyn") return { kind: "libCall", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json new file mode 100644 index 000000000..4081fc5ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-reflect-object-reflections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8426-reflect-object-reflections.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8426-reflect-object-reflections.ts b/tests/corpus/8426-reflect-object-reflections.ts new file mode 100644 index 000000000..bae308d81 --- /dev/null +++ b/tests/corpus/8426-reflect-object-reflections.ts @@ -0,0 +1,28 @@ +// Reflect.has over the checked-dynamic view, and the Object reflection +// operations over records, arrays and class instances (own fields and +// elements in property order). Reflect.has is [[HasProperty]], so inherited +// names (Array.prototype's, Object.prototype's, a class's methods) answer +// true; a primitive target is Node's TypeError and stays a lowerer fence +// (SC2020) rather than a silent `false`. +class Point { + x = 1; + y = 2; + sum(): number { + return this.x + this.y; + } +} +const record = { a: 1, b: "two" }; +const list = [10, 20]; +const point = new Point(); +console.log(Reflect.has(record, "a"), Reflect.has(record, "z")); +console.log(Reflect.has(list, "0"), Reflect.has(list, "length"), Reflect.has(list, "9")); +console.log(Reflect.has(point, "x"), Reflect.has(point, "sum")); +const key = "b"; +console.log(Reflect.has(record, key), Reflect.has(record, key.toUpperCase())); +console.log(Reflect.has(record, "toString"), Reflect.has(record, "constructor")); +console.log(Object.getOwnPropertyNames(record).join(",")); +console.log(Object.getOwnPropertyNames(point).join(",")); +console.log(Object.getOwnPropertyNames(list).length); +const descriptors = Object.getOwnPropertyDescriptors(record); +console.log(Object.keys(descriptors).join(",")); +console.log(Object.getOwnPropertySymbols(record).length); diff --git a/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json b/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json new file mode 100644 index 000000000..a1a93d6be --- /dev/null +++ b/tests/harness/corpus-costs/8426-reflect-object-reflections.ts.json @@ -0,0 +1 @@ +{ "plain": 3121, "san": 5843 } From f5c404c3ff858f423e00c4b651a351391a43bdab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:21:02 -0700 Subject: [PATCH 11/15] fs: copyFile's mode argument and writeFile's `flag` option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copyFileSync/copyFile took exactly two arguments; the third is libuv's copyfile flag bits (COPYFILE_EXCL 1, COPYFILE_FICLONE 2, FICLONE_FORCE 4 — fs.constants.* reads bake to literals). The surface row now carries the F64, and both forms lower to new flag-aware runtime entries that reuse the existing recursive-copy helper (reflink via ioctl, EXCL through open) after Node's own range check: a mode outside [0, 7] throws the RangeError "mode is out of range: >= 0 && <= 7". writeFileSync/writeFile fenced every `flag` option although "w" (the default truncate-or-create) and "wx" (O_EXCL: EEXIST when the path exists) write the bytes the lowered path already writes. Both literal flags now lower; every other flag still fences by name because it writes different bytes. The exclusive form is a separate runtime entry so the open flags stay compile-time known. Corpus 8427 pins the sync and promise forms of both against Node, including the EEXIST and range errors, the mode-plus-flag combination and the async twins. Co-Authored-By: Claude Opus 5.5 --- .../ambient/node-fallback/fs-promises.d.ts | 2 +- .../compiler/ambient/node-fallback/fs.d.ts | 14 ++- .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../lowering/builtins/filesystem-calls.ts | 71 ++++++++++---- .../lowering/builtins/module-calls.ts | 36 ++++++- .../src/frontend/lowering/surfaces.ts | 25 +++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 21 ++++ .../8427-fs-copyfile-writefile-flags.ts.json | 6 ++ packages/runtime/src/scr_async.c | 10 ++ packages/runtime/src/scr_fs_cp.c | 19 ++++ packages/runtime/src/scr_lib.c | 97 +++++++++++++++++-- packages/runtime/src/scr_lib.h | 7 ++ .../8427-fs-copyfile-writefile-flags.ts | 63 ++++++++++++ .../8427-fs-copyfile-writefile-flags.ts.json | 1 + 15 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json create mode 100644 tests/corpus/8427-fs-copyfile-writefile-flags.ts create mode 100644 tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json diff --git a/packages/compiler/ambient/node-fallback/fs-promises.d.ts b/packages/compiler/ambient/node-fallback/fs-promises.d.ts index 6f3f2c460..e5d7827d0 100644 --- a/packages/compiler/ambient/node-fallback/fs-promises.d.ts +++ b/packages/compiler/ambient/node-fallback/fs-promises.d.ts @@ -210,7 +210,7 @@ declare module "fs/promises" { export function access(path: string | URL, mode?: number): Promise; export function truncate(path: string | URL, len?: number): Promise; export function appendFile(path: string | URL, data: string | Uint8Array): Promise; - export function copyFile(src: string | URL, dest: string | URL): Promise; + export function copyFile(src: string | URL, dest: string | URL, mode?: number): Promise; export function mkdtemp(prefix: string | URL): Promise; export function rmdir(path: string | URL): Promise; export function rename(oldPath: string | URL, newPath: string | URL): Promise; diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..b8eb73357 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -30,8 +30,10 @@ declare module "node:fs" { export function writeFileSync( path: string | URL, data: string, - /* The options-record stance (see http.RequestOptions): flag fences - * by name at the call site; undocumented keys drop like Node. */ + /* The options-record stance (see http.RequestOptions): an unsupported + * flag fences by name at the call site; undocumented keys drop like + * Node. The lowered flags are "w" (truncate-or-create) and "wx" + * (exclusive create). */ options?: { mode?: number; encoding?: "utf8" | "utf-8"; @@ -157,9 +159,11 @@ declare module "node:fs" { export function lchownSync(path: string | URL, uid: number, gid: number): void; export function fchownSync(fd: number, uid: number, gid: number): void; export function truncateSync(path: string | URL, len?: number): void; - /* The 2-argument form only (Node's mode flags have no lowering). The - * destination is created or truncated carrying the SOURCE's mode. */ - export function copyFileSync(src: string | URL, dest: string | URL): void; + /* The destination is created or truncated carrying the SOURCE's mode. + * `mode` is libuv's copyfile flag bits: COPYFILE_EXCL (1) fails when the + * destination exists, COPYFILE_FICLONE (2) requests a reflink and falls + * back to a copy, COPYFILE_FICLONE_FORCE (4) requires one. */ + export function copyFileSync(src: string | URL, dest: string | URL, mode?: number): void; export function rename( oldPath: string, newPath: string, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index cadeac305..928a9caa0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -444,6 +444,7 @@ export const LIB_FN_SYMS: Record = { "fs.unlinkSync": "scr_fs_unlink", "fs.chmodSync": "scr_fs_chmod", "fs.copyFileSync": "scr_fs_copyfile", + "fs.copyFileFlagsSync": "scr_fs_copyfile_flags", "fs.renameSync": "scr_fs_rename", "fs.accessSync": "scr_fs_access", // node:assert (scr_assert.c): all args borrowed; failures throw the @@ -883,6 +884,8 @@ export const LIB_FN_SYMS: Record = { "fsp.readFileBytes": "scr_fsp_read_file_bytes", "fsp.writeFile": "scr_fsp_write_file", "fsp.writeFileMode": "scr_fsp_write_file_mode", + "fsp.writeFileFlags": "scr_fsp_write_file_flags", + "fsp.copyFileFlags": "scr_fsp_copyfile_flags", "fsp.mkdir": "scr_fsp_mkdir", "fsp.mkdirMode": "scr_fsp_mkdir_mode", "fsp.mkdirRecursive": "scr_fsp_mkdir_recursive", @@ -1237,6 +1240,7 @@ export const LIB_FN_SYMS: Record = { "crypto.x509Verify": "scr_crypto_x509_verify", "crypto.x509CheckPrivateKey": "scr_crypto_x509_check_private_key", "fs.writeFileModeSync": "scr_fs_write_file_mode", + "fs.writeFileFlagsSync": "scr_fs_write_file_flags", "fs.chownSync": "scr_fs_chown", "fs.lchownSync": "scr_fs_lchown", "fs.fchownSync": "scr_fs_fchown", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 9bb9ee404..f86393e55 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -1049,8 +1049,14 @@ export function lowerFsWriteOptionsCall( const operation = promiseWriteOptions ? "fs.promises.writeFile" : "writeFileSync"; const plainFn: IrLibFn = promiseWriteOptions ? "fsp.writeFile" : "fs.writeFileSync"; const modeFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileMode" : "fs.writeFileModeSync"; + const flagsFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileFlags" : "fs.writeFileFlagsSync"; const resultType: IrType = promiseWriteOptions ? { kind: "promise", inner: VOID } : VOID; type WriteOptionValue = { kind: "effect" | "mode"; value: IrExpr }; + /** `flag: "w"` is the plain truncate-or-create write; `flag: "wx"` is + * open(2)'s O_EXCL (EEXIST when the path exists). Both are literals, so + * the bit is known here and carries no evaluation of its own. */ + let exclusive = false; + let sawFlag = false; // The runtime needs only `mode`, but every source option value is an // ordinary JS expression. Stage path/data and then evaluate the option // values in object-literal order before issuing the write; otherwise a @@ -1079,19 +1085,36 @@ export function lowerFsWriteOptionsCall( }); mode = varRef(modeLocal.id, modeLocal.type, loc); } - const result: IrExpr = { - kind: "libCall", - fn: mode ? modeFn : plainFn, - args: mode - ? [ + const result: IrExpr = sawFlag + ? { + kind: "libCall", + fn: flagsFn, + args: [ varRef(pathLocal.id, pathLocal.type, loc), varRef(dataLocal.id, dataLocal.type, loc), - mode, - ] - : [varRef(pathLocal.id, pathLocal.type, loc), varRef(dataLocal.id, dataLocal.type, loc)], - type: resultType, - loc, - }; + // Node's default creation mode when only the flag is given. + mode ?? { kind: "numLit", value: 0o666, type: F64, loc }, + { kind: "numLit", value: exclusive ? 1 : 0, type: F64, loc }, + ], + type: resultType, + loc, + } + : { + kind: "libCall", + fn: mode ? modeFn : plainFn, + args: mode + ? [ + varRef(pathLocal.id, pathLocal.type, loc), + varRef(dataLocal.id, dataLocal.type, loc), + mode, + ] + : [ + varRef(pathLocal.id, pathLocal.type, loc), + varRef(dataLocal.id, dataLocal.type, loc), + ], + type: resultType, + loc, + }; return { kind: "seqExpr", stmts, result, type: resultType, loc }; }; // The bare-encoding spelling — writeFileSync(p, data, "utf-8") — is @@ -1134,13 +1157,21 @@ export function lowerFsWriteOptionsCall( } optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) }); } else if (m.name === "flag") { - // Documented, behavior-changing (open(2)'s disposition — 'a' - // IS appendFileSync), no lowering: fence by name. - lowerer.noLowering( - `${operation} with the flag option`, - p, - "the write truncates-or-creates (Node's default 'w'); other flags have no lowering", - ); + // Node's disposition flag changes whether an existing file is + // truncated: "w" is the default write, "wx" the exclusive create. + // Every other flag (append "a"/"as", synchronous "ws", ...) writes + // different bytes and keeps the fence by name. + const t = lowerer.typeOf(m.value); + if (!t.isStringLiteralType() || (t.value !== "w" && t.value !== "wx")) { + lowerer.noLowering( + `${operation} with the flag option`, + p, + 'the lowered flags are "w" (the default truncate-or-create) and "wx" (O_EXCL: EEXIST when the path exists); other flags write different bytes', + ); + } + exclusive = t.value === "wx"; + sawFlag = true; + optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) }); } else { // The options-record stance: documented keys with no lowering // fence by name; undocumented keys drop like Node. @@ -1150,7 +1181,7 @@ export function lowerFsWriteOptionsCall( m.name, operation, FS_WRITE_FILE_DOCUMENTED_OPTIONS, - 'the supported options are { mode: , encoding: "utf8" }', + 'the supported options are { mode: , encoding: "utf8", flag: "w" | "wx" }', ); } } @@ -1159,7 +1190,7 @@ export function lowerFsWriteOptionsCall( lowerer.noLowering( `${operation} with 3 arguments`, optsNode, - 'the supported options are { mode: , encoding: "utf8" } over string data', + 'the supported options are { mode: , encoding: "utf8", flag: "w" | "wx" } over string data', ); } return finishWrite(optionValues); diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index bf5223d2f..b98e34782 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -35,7 +35,16 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { type BuiltinModuleFn } from "../surfaces.js"; import { type ParamShape } from "../call-signatures.js"; -import { BYTES_U8, DYN, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js"; +import { + BYTES_U8, + DYN, + F64, + type IrExpr, + type IrLibFn, + 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"; @@ -323,6 +332,31 @@ export function lowerBuiltinModuleCall( : { kind: "numLit", value: 0, type: F64, loc }; return { kind: "libCall", fn: fn.fn, args: [path, second], type: fn.result, loc }; } + // copyFileSync(src, dest, mode?) and copyFile(src, dest, mode?): the + // mode is libuv's copyfile flag bits (COPYFILE_EXCL = 1, COPYFILE_FICLONE + // = 2, COPYFILE_FICLONE_FORCE = 4 — fs.constants.* reads bake to + // literals). The omitted form is the plain copy and keeps its libCall. + if ( + (bi.module === "fs" && bi.member === "copyFileSync") || + (bi.module === "fs/promises" && bi.member === "copyFile") + ) { + const count = expr.arguments.nodes.length; + if (count < 2 || count > 3 || expr.arguments.nodes.some(ts.isSpreadElement)) { + lowerer.noLowering(`${bi.member} with ${count} arguments`, expr); + } + const src = lowerFsPath(lowerer, expr.arguments.nodes[0]!); + const dest = lowerFsPath(lowerer, expr.arguments.nodes[1]!); + const modeNode = expr.arguments.nodes[2]; + if (!modeNode) return { kind: "libCall", fn: fn.fn, args: [src, dest], type: fn.result, loc }; + const flags: IrLibFn = bi.module === "fs" ? "fs.copyFileFlagsSync" : "fsp.copyFileFlags"; + return { + kind: "libCall", + fn: flags, + args: [src, dest, lowerer.lowerExprExpecting(modeNode, F64)], + type: fn.result, + loc, + }; + } if ( bi.module === "fs/promises" && bi.member === "appendFile" && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..eb09c4291 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1202,9 +1202,9 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { // truncateSync's omitted len completes to 0 in lowerBuiltinModuleCall, // like accessSync's mode. truncateSync: { fn: "fs.truncateSync", params: [STRING, F64], result: VOID }, - // The 2-argument form only: Node's mode flags (COPYFILE_EXCL, ...) - // land on the arity fence. - copyFileSync: { fn: "fs.copyFileSync", params: [STRING, STRING], result: VOID }, + // The mode is libuv's copyfile flag bits (COPYFILE_EXCL, ...); the + // 2- and 3-argument forms lower in lowerBuiltinModuleCall. + copyFileSync: { fn: "fs.copyFileSync", params: [STRING, STRING, F64], result: VOID }, // The callback form is special-cased in lowerBuiltinModuleCall: its // Error | null parameter is program-shaped and needs an emitted // adapter. The row still projects the static surface and routes the @@ -1410,10 +1410,10 @@ function buildBuiltinModuleFns(): BuiltinModuleFnTable { params: [STRING, STRING], result: { kind: "promise", inner: VOID }, }, - // The 2-argument form only, like copyFileSync. + // The 2- and 3-argument forms, like copyFileSync. copyFile: { fn: "fsp.copyFile", - params: [STRING, STRING], + params: [STRING, STRING, F64], result: { kind: "promise", inner: VOID }, }, mkdtemp: { fn: "fsp.mkdtemp", params: [STRING], result: { kind: "promise", inner: STRING } }, @@ -2191,15 +2191,20 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< ], // The string source's check before a URL destination converts // (fs-paths.ts). - copyFileSync: ["fs.checkPath"], + copyFileSync: ["fs.checkPath", "fs.copyFileFlagsSync"], renameSync: ["fs.checkPath"], - copyFile: ["fs.checkPath"], + copyFile: ["fs.checkPath", "fsp.copyFileFlags"], rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. // The bytes-data form, the { mode } options form, and the // file-descriptor data form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], + writeFileSync: [ + "fs.writeFileSyncBytes", + "fs.writeFileModeSync", + "fs.writeFileFlagsSync", + "fs.writeFileSyncFd", + ], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ @@ -2250,8 +2255,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< // The Buffer form (no encoding) and the runtime-encoding forms. readFile: ["fsp.readFileBytes", "fsp.readFileEncoded"], readlink: ["fsp.readlinkStr", "fsp.readlinkBuffer"], - // The string-data { mode } options form. - writeFile: ["fsp.writeFileMode"], + // The string-data { mode, flag } options forms. + writeFile: ["fsp.writeFileMode", "fsp.writeFileFlags"], // The { withFileTypes: true } Dirent form. readdir: ["fsp.readdirTypes"], glob: ["fsp.globTypes"], diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 53ad0b583..8f8fa9be1 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1057,6 +1057,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dir.readSync", "dir.closeSync", "fs.copyFileSync", + "fs.copyFileFlagsSync", "fs.renameSync", "fs.renameCb", "fs.lstatSync", @@ -1079,6 +1080,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.readlinkSyncStr", "fs.readlinkSyncBuffer", "fs.writeFileModeSync", + "fs.writeFileFlagsSync", "fs.mkdirModeSync", "fs.mkdirRecursiveModeSync", "fs.openSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 8821c13a1..4b64f12e1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1418,6 +1418,12 @@ function buildLibFnSignatures() { * clamped like ftruncateSync's) and closes it. */ "fs.truncateSync": { argTypes: [STRING, F64], result: VOID }, "fs.copyFileSync": { argTypes: [STRING, STRING], result: VOID }, + /** copyFileSync(src, dest, mode): libuv's uv_fs_copyfile flag bits — + * COPYFILE_EXCL (1) fails on an existing destination, COPYFILE_FICLONE + * (2) requests a reflink and falls back to a copy, COPYFILE_FICLONE_FORCE + * (4) requires one. Out-of-range modes throw the RangeError Node throws + * ("mode is out of range: >= 0 && <= 7"). */ + "fs.copyFileFlagsSync": { argTypes: [STRING, STRING, F64], result: VOID }, /** renameSync is the direct two-path syscall wrapper. renameCb defers * the operation to the loop and fires a program-shaped Error | null * callback through a backend-emitted adapter. */ @@ -1425,6 +1431,10 @@ function buildLibFnSignatures() { // Callback type is program-dependent (zero params or Error | null). "fs.renameCb": { argTypes: [STRING, STRING, null], result: VOID }, "fs.writeFileModeSync": { argTypes: [STRING, STRING, F64], result: VOID }, + /** writeFileSync(path, data, { mode, flag }): the same open(2) write + * with the disposition flag — flag 1 is "wx" (O_EXCL: EEXIST when the + * path exists), 0 is "w" (the default truncate-or-create). */ + "fs.writeFileFlagsSync": { argTypes: [STRING, STRING, F64, F64], result: VOID }, "fs.mkdirModeSync": { argTypes: [STRING, F64], result: VOID }, "fs.mkdirRecursiveModeSync": { argTypes: [STRING, F64], result: VOID }, "atomics.wait": { argTypes: [bytesOf("i32"), F64, F64, F64], result: STRING }, @@ -3454,6 +3464,17 @@ function buildLibFnSignatures() { argTypes: [STRING, STRING, F64], result: { kind: "promise", inner: VOID }, }, + /** fs.promises.writeFile(path, data, { mode, flag }) and + * fs.promises.copyFile(src, dest, mode): the settled-promise twins of + * fs.writeFileFlagsSync and fs.copyFileFlagsSync. */ + "fsp.writeFileFlags": { + argTypes: [STRING, STRING, F64, F64], + result: { kind: "promise", inner: VOID }, + }, + "fsp.copyFileFlags": { + argTypes: [STRING, STRING, F64], + result: { kind: "promise", inner: VOID }, + }, "fsp.mkdir": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, /** The fs/promises option/member tail the certs pipeline uses: mkdir's * literal { recursive?, mode? } options (the mkdirSync matrix behind diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json new file mode 100644 index 000000000..3c06d6664 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-fs-copyfile-writefile-flags.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8427-fs-copyfile-writefile-flags.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 0225269f4..f26d520f9 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3234,6 +3234,11 @@ ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + scr_fs_write_file_flags(path, data, mode, exclusive); + return scr_promise_settled_void(); +} + ScrPromise *scr_fsp_mkdir(ScrStr *path) { scr_fs_mkdir(path); return scr_promise_settled_void(); @@ -3294,6 +3299,11 @@ ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + scr_fs_copyfile_flags(src, dest, mode); + return scr_promise_settled_void(); +} + /* readFile(path, encoding | { encoding }): the encoding is checked first, * so an unknown name rejects before the read. */ ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding) { diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c index 8a9ebec8b..f6c1fd13b 100644 --- a/packages/runtime/src/scr_fs_cp.c +++ b/packages/runtime/src/scr_fs_cp.c @@ -589,6 +589,25 @@ static void cp_copyfile(const ScrStr *src, const ScrStr *dest, int mode, const c if (e) scr_fs_throw2(e, syscall, src, dest); } +/* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's + * uv_fs_copyfile with Node's flag bits (the C++ binding's range check and + * message: any value in [0, 7], truncated to an integer — 1.5 is EXCL). + * COPYFILE_EXCL (1) fails EEXIST on an existing destination, + * COPYFILE_FICLONE (2) requests a reflink and falls back to a copy, and + * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. This file + * excludes _WIN32, whose arm (CopyFileW and its ENOSYS for FICLONE_FORCE) + * lives in scr_lib.c beside the Win32 fs helpers. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + if (!(mode >= 0 && mode <= 7)) { + static const char text[] = "mode is out of range: >= 0 && <= 7"; + scr_throw_error_msg_code(SCR_ERR_RANGE, text, sizeof text - 1, "ERR_OUT_OF_RANGE"); + return; + } + if (!scr_fs_path_ok(src, "src")) return; + if (!scr_fs_path_ok(dest, "dest")) return; + cp_copyfile(src, dest, (int)mode, "copyfile"); +} + /* utimes(dest, atime, mtime) in seconds, through the utimesSync path. */ static bool cp_utime(const ScrStr *dest, double atime, double mtime) { scr_fs_utimes((ScrStr *)dest, atime, mtime, false); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 67f18c0d8..d4d84a6e8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3531,12 +3531,9 @@ void scr_fs_write_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "wb"); } -/* writeFileSync(p, data, { mode }): the mode is open(2)'s O_CREAT - * argument — it applies at CREATION only (umask applying), and an - * existing file keeps its permissions, exactly Node (which never chmods - * here). Same error shapes as the plain form. */ -void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { - if (!scr_fs_path_ok(path, "path")) return; +/* Node's mode validation for the options-record write: an integer in + * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ +static bool scr_fs_write_mode_ok(double mode) { char recv[48], msg[176]; scr_num_received(mode, recv); if (!(isfinite(mode) && trunc(mode) == mode)) { @@ -3544,18 +3541,27 @@ void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { "The value of \"mode\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } if (mode < 0 || mode > 4294967295.0) { int len = snprintf(msg, sizeof msg, "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } + return true; +} + +/* The open(2) write behind the mode/flag forms: O_TRUNC plus `extra` (only + * O_EXCL today), creation mode applying at CREATION only (umask applying) — + * an existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { + if (!scr_fs_path_ok(path, "path")) return; /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating * \n in these byte-exact writes (fopen's "wb" path already does). */ - int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, (mode_t)mode); + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); if (fd < 0) { scr_fs_throw(errno, "open", path); return; @@ -3579,6 +3585,22 @@ void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { if (close(fd) != 0) scr_fs_throw(errno, "close", path); } +/* writeFileSync(p, data, { mode }). */ +void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { + if (!scr_fs_path_ok(path, "path")) return; + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, 0); +} + +/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the + * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path + * exists); 0 is "w", the default truncate-or-create. */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + if (!scr_fs_path_ok(path, "path")) return; + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); +} + void scr_fs_append_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "ab"); } @@ -4648,6 +4670,63 @@ static int scr_fs_win_errno(DWORD error) { return EIO; } } + +/* libuv's fs__stat_impl_from_path identity comparison (volume serial and + * file index): the same-file CopyFileW failure below is a success. */ +static bool scr_fs_win_same_file(const WCHAR *a, const WCHAR *b) { + BY_HANDLE_FILE_INFORMATION first, second; + HANDLE handle = CreateFileW(a, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (handle == INVALID_HANDLE_VALUE) return false; + BOOL ok = GetFileInformationByHandle(handle, &first); + CloseHandle(handle); + if (!ok) return false; + handle = CreateFileW(b, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (handle == INVALID_HANDLE_VALUE) return false; + ok = GetFileInformationByHandle(handle, &second); + CloseHandle(handle); + if (!ok) return false; + return first.dwVolumeSerialNumber == second.dwVolumeSerialNumber && + first.nFileIndexHigh == second.nFileIndexHigh && + first.nFileIndexLow == second.nFileIndexLow; +} + +/* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's + * fs__copyfile in src/win/fs.c. CopyFileW carries only COPYFILE_EXCL (its + * bFailIfExists flag); COPYFILE_FICLONE (2) falls back to a plain copy and + * COPYFILE_FICLONE_FORCE (4) is the ENOSYS uv_fs_copyfile reports. The + * POSIX arm lives in scr_fs_cp.c with its reflink handling. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + if (!(mode >= 0 && mode <= 7)) { + static const char text[] = "mode is out of range: >= 0 && <= 7"; + scr_throw_error_msg_code(SCR_ERR_RANGE, text, sizeof text - 1, "ERR_OUT_OF_RANGE"); + return; + } + if (!scr_fs_path_ok(src, "src")) return; + if (!scr_fs_path_ok(dest, "dest")) return; + if (((int)mode & 4) != 0) { + scr_fs_throw2(ENOSYS, "copyfile", src, dest); + return; + } + WCHAR *from = scr_fs_win_wide(src); + WCHAR *to = scr_fs_win_wide(dest); + if (!from || !to) { + DWORD error = GetLastError(); + free(from); + free(to); + scr_fs_throw2(scr_fs_win_errno(error), "copyfile", src, dest); + return; + } + BOOL ok = CopyFileW(from, to, ((int)mode & 1) ? TRUE : FALSE); + int error = ok ? 0 : scr_fs_win_errno(GetLastError()); + if (!ok && error == EBUSY && scr_fs_win_same_file(from, to)) error = 0; + free(from); + free(to); + if (error != 0) scr_fs_throw2(error, "copyfile", src, dest); +} #endif int scr_fs_rename_raw(const ScrStr *oldpath, const ScrStr *newpath) { diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index fa88873af..28a0335f0 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -346,6 +346,9 @@ bool scr_fs_access_mode_valid(double mode); bool scr_fs_truncate_len_valid(double length); void scr_fs_truncate(ScrStr *path, double length); void scr_fs_copyfile(ScrStr *src, ScrStr *dest); +/* copyFileSync(src, dest, mode): libuv's copyfile flag bits (EXCL 1, + * FICLONE 2, FICLONE_FORCE 4); out-of-range modes throw Node's RangeError. */ +void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode); /* The two-path form of scr_fs_throw: "ENOENT: no such file or directory, * copyfile 'src' -> 'dest'", with path and dest own properties. */ void scr_fs_throw2(int e, const char *op, const ScrStr *src, const ScrStr *dest); @@ -367,6 +370,8 @@ void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath); int scr_fs_rename_raw(const ScrStr *oldpath, const ScrStr *newpath); void scr_fs_rename_error(int error, const ScrStr *oldpath, const ScrStr *newpath); void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode); +/* writeFileSync(path, data, { mode, flag }): `exclusive` is "wx" (O_EXCL). */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive); void scr_fs_mkdir_mode(ScrStr *path, double mode); void scr_fs_mkdir_recursive_mode(ScrStr *path, double mode); /* The same walk, answering the first directory created (+1) or NULL @@ -493,6 +498,7 @@ ScrDyn *scr_stats_dyn(ScrStats *s, bool bigint); ScrPromise *scr_fsp_read_file(ScrStr *path); ScrPromise *scr_fsp_write_file(ScrStr *path, ScrStr *data); ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode); +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive); ScrPromise *scr_fsp_mkdir(ScrStr *path); ScrPromise *scr_fsp_mkdir_mode(ScrStr *path, double mode); ScrPromise *scr_fsp_mkdir_recursive(ScrStr *path); @@ -505,6 +511,7 @@ ScrPromise *scr_fsp_access(ScrStr *path, double mode); ScrPromise *scr_fsp_append_file(ScrStr *path, ScrStr *data); ScrPromise *scr_fsp_append_file_bytes(ScrStr *path, ScrBytes *data); ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest); +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode); ScrPromise *scr_fsp_mkdtemp(ScrStr *prefix); ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding); ScrPromise *scr_fsp_rmdir(ScrStr *path); diff --git a/tests/corpus/8427-fs-copyfile-writefile-flags.ts b/tests/corpus/8427-fs-copyfile-writefile-flags.ts new file mode 100644 index 000000000..44b7c4e9b --- /dev/null +++ b/tests/corpus/8427-fs-copyfile-writefile-flags.ts @@ -0,0 +1,63 @@ +// copyFileSync/copyFile's third argument (libuv's flag bits) and +// writeFileSync/writeFile's `flag` option ("w" and the exclusive "wx"). +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync, constants } from "node:fs"; +import { copyFile, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dir = mkdtempSync(join(tmpdir(), "scriptc-flags-")); +const src = join(dir, "src.txt"); +const dst = join(dir, "dst.txt"); +writeFileSync(src, "payload"); +copyFileSync(src, dst, constants.COPYFILE_EXCL); +console.log(readFileSync(dst, "utf8")); +try { + copyFileSync(src, dst, constants.COPYFILE_EXCL); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +copyFileSync(src, dst); +console.log(readFileSync(dst, "utf8")); +try { + copyFileSync(src, join(dir, "x.txt"), 8); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +const wx = join(dir, "wx.txt"); +writeFileSync(wx, "one", { flag: "wx" }); +console.log(readFileSync(wx, "utf8")); +try { + writeFileSync(wx, "two", { flag: "wx" }); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +writeFileSync(wx, "three", { flag: "w" }); +console.log(readFileSync(wx, "utf8")); +const modeFile = join(dir, "mode.txt"); +writeFileSync(modeFile, "m", { mode: 0o600, flag: "wx" }); +console.log(readFileSync(modeFile, "utf8")); +await copyFile(src, join(dir, "p.txt"), constants.COPYFILE_EXCL); +console.log(await readFile(join(dir, "p.txt"), "utf8")); +try { + await copyFile(src, join(dir, "p.txt"), constants.COPYFILE_EXCL); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +const pwx = join(dir, "pwx.txt"); +await writeFile(pwx, "async-one", { flag: "wx" }); +console.log(await readFile(pwx, "utf8")); +try { + await writeFile(pwx, "async-two", { flag: "wx" }); + console.log("no throw"); +} catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(err.code, err.syscall); +} +rmSync(dir, { recursive: true, force: true }); diff --git a/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json b/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json new file mode 100644 index 000000000..495c7b29b --- /dev/null +++ b/tests/harness/corpus-costs/8427-fs-copyfile-writefile-flags.ts.json @@ -0,0 +1 @@ +{ "plain": 2673, "san": 4238 } From 5c1f32f5f16516f3f090895731a8c359a7df1de1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:21:17 -0700 Subject: [PATCH 12/15] string_decoder: write/end over a checked-dynamic chunk A stream's "data" payload arrives as checked-dynamic, and decoder.write/ decoder.end fenced it (SC2020 'StringDecoder.write of 'unknown' data'). The dyn route reads the chunk as Node does: a string returns unchanged without touching the buffered state, an ArrayBufferView (Buffer, TypedArray, DataView) decodes through the existing per-encoding helpers with the packed-f64 pending state threaded through, and anything else throws ERR_INVALID_ARG_TYPE for "buf" with Node's message. The two runtime entries live in scr_bytes.c beside their static twins and share the same `StringDecoder.write`/`end` lowering, so a program that only ever passes typed chunks keeps the existing libCalls (zero-cost). Corpus 8428 pins the string pass-through, the utf8/base64 state across an interleaved string chunk, and both argument-type errors against Node. This commit also carries this wave's compatibility evidence: the fs, url and string_decoder corpus programs are recorded in static-support.json and the ledger is regenerated with pnpm node-compat --local. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 27 ++++-- internal/compatibility/static-support.json | 11 ++- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../frontend/lowering/builtins/text-codecs.ts | 90 ++++++++++++++++++- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ ...8428-string-decoder-dynamic-chunks.ts.json | 6 ++ packages/runtime/src/scr_bytes.c | 39 ++++++++ packages/runtime/src/scr_bytes.h | 5 ++ .../8428-string-decoder-dynamic-chunks.ts | 46 ++++++++++ ...8428-string-decoder-dynamic-chunks.ts.json | 1 + 11 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json create mode 100644 tests/corpus/8428-string-decoder-dynamic-chunks.ts create mode 100644 tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 00d6f8db3..b320864bc 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -44327,6 +44327,7 @@ "tests/corpus/5570-fs-cp-async.ts", "tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts", "tests/corpus/fs-native-realpath.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", @@ -45350,7 +45351,8 @@ "evidence": "surface-manifest:node-builtin.fs.promises.copyFile", "tests": [ "tests/corpus/5542-fs-promises-parity.cjs", - "tests/corpus/5543-fs-promises-typed.ts" + "tests/corpus/5543-fs-promises-typed.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -46091,7 +46093,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -47954,7 +47957,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -49006,7 +49010,8 @@ "tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", - "tests/corpus/2920-fs-promises-function-values.ts" + "tests/corpus/2920-fs-promises-function-values.ts", + "tests/corpus/8427-fs-copyfile-writefile-flags.ts" ] }, "anchorSource": "exact" @@ -91296,6 +91301,7 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts", "tests/corpus/node24-string-decoder.ts" ] }, @@ -91324,7 +91330,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -91352,7 +91359,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -91380,7 +91388,8 @@ "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", - "tests/corpus/5882-string-decoder-runtime-encoding.ts" + "tests/corpus/5882-string-decoder-runtime-encoding.ts", + "tests/corpus/8428-string-decoder-dynamic-chunks.ts" ] }, "anchorSource": "exact" @@ -99682,6 +99691,7 @@ "tests/corpus/5229-url-legacy-errors.cjs", "tests/corpus/5833-url-search-params-iterators.ts", "tests/corpus/5834-url-search-params-iterators-dynamic.mjs", + "tests/corpus/8423-urlsearchparams-implicit-string.ts", "tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-errors.js", "tests/corpus/node24-file-url-options.ts", @@ -100931,7 +100941,8 @@ "status": "partial", "evidence": "surface-manifest:node-builtin.url.urlToHttpOptions", "tests": [ - "tests/corpus/1794-searchparams-url-live.ts" + "tests/corpus/1794-searchparams-url-live.ts", + "tests/corpus/8423-urlsearchparams-implicit-string.ts" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1242ad9a7..6fd5980b6 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -59,6 +59,8 @@ "node-builtin.v8.getHeapStatistics": ["tests/corpus/v8-heap-statistics.ts", "tests/corpus/v8-require-optional.cjs"], "node-builtin.events": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"], "node-builtin.fs": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts"], + "node-builtin.fs.copyFileSync": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], + "node-builtin.fs.writeFileSync": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], "node-builtin.fs.realpathSync.native": ["tests/corpus/fs-native-realpath.ts"], "node-builtin.fs.fchmodSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], "node-builtin.fs.fstatSync": ["tests/corpus/2950-fs-descriptor-publication.ts"], @@ -71,7 +73,8 @@ "node-builtin.fs.promises.utimes": ["tests/corpus/node24-fs-timestamps-promises.ts"], "node-builtin.fs.promises.access": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.appendFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], - "node-builtin.fs.promises.copyFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], + "node-builtin.fs.promises.copyFile": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], + "node-builtin.fs.promises.writeFile": ["tests/corpus/1403-buffer-fs.ts", "tests/corpus/2595-fs-arg-ladders.cjs", "tests/corpus/2919-fs-function-values.ts", "tests/corpus/2920-fs-promises-function-values.ts", "tests/corpus/8427-fs-copyfile-writefile-flags.ts"], "node-builtin.fs.promises.mkdtemp": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.rmdir": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], "node-builtin.fs.promises.truncate": ["tests/corpus/5542-fs-promises-parity.cjs", "tests/corpus/5543-fs-promises-typed.ts"], @@ -169,12 +172,12 @@ "node-builtin.querystring": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2917-builtin-callable-widths.ts"], "node-builtin.readline": ["tests/corpus/1475-readline-closed-stdin.ts"], "node-builtin.stream": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1746-stream-for-await.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/2629-stream-consumers.ts", "tests/corpus/2861-readable-iterator-close.ts"], - "node-builtin.string_decoder": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts"], + "node-builtin.string_decoder": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/8428-string-decoder-dynamic-chunks.ts"], "node-builtin.test": ["tests/harness/node-test.test.ts"], "node-builtin.timers": ["tests/corpus/1804-timers-module.ts", "tests/corpus/2093-timers-promises.ts"], "node-builtin.tls": ["tests/corpus/2331-tls-server-options-typed.ts", "tests/corpus/2557-tls-ca-store.ts"], "node-builtin.tty": ["tests/corpus/1448-stream-columns.ts", "tests/corpus/terminal-stream-properties.ts", "tests/harness/terminal-geometry.test.ts"], - "node-builtin.url": ["tests/corpus/1794-searchparams-url-live.ts"], + "node-builtin.url": ["tests/corpus/1794-searchparams-url-live.ts", "tests/corpus/8423-urlsearchparams-implicit-string.ts"], "node-builtin.url.pathToFileURL": ["tests/corpus/path-to-file-url-options.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPath": ["tests/corpus/file-url-percent-decoding.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js"], "node-builtin.url.fileURLToPathBuffer": ["tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js", "tests/fixtures/node-types/file-url-conversion.ts"], @@ -475,7 +478,7 @@ { "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] }, { "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts", "tests/corpus/5846-events-esm-default-max.ts", "tests/corpus/5847-events-cjs-default-max.cjs"] }, { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable", "asyncLocalStorage.name", "AsyncLocalStorage.bind", "AsyncLocalStorage.snapshot"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts", "tests/corpus/7110-als-completion-callbacks.ts", "tests/corpus/7111-als-bind-snapshot-options.ts", "tests/corpus/7112-als-bind-snapshot-dynamic.mjs", "tests/corpus/7119-als-net-event-context.mjs", "tests/corpus/7380-als-http-event-context.mjs", "tests/corpus/7383-als-stream-events.ts", "tests/corpus/7384-als-stream-events.mjs", "tests/corpus/7385-als-child-process-events.ts", "tests/corpus/7386-als-child-fork-events/main.mjs", "tests/corpus/7387-als-http2-events.mjs", "tests/corpus/7388-als-http2-compat-events.mjs", "tests/corpus/7389-als-http2-secure-events.mjs", "tests/corpus/7391-als-response-end-and-finished.mjs"] }, - { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] }, + { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts", "tests/corpus/8428-string-decoder-dynamic-chunks.ts"] }, { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 928a9caa0..81d50a45c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -593,6 +593,8 @@ export const LIB_FN_SYMS: Record = { // string_decoder (scr_bytes.c): stateless helpers over (enc, pending, // chunk) — never throw. "strdec.write": "scr_strdec_write", + "strdec.writeDyn": "scr_strdec_write_dyn", + "strdec.nextDyn": "scr_strdec_next_dyn", "strdec.next": "scr_strdec_next", "strdec.end": "scr_strdec_end", "strdec.encoding": "scr_strdec_encoding", diff --git a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts index 39156940e..1100523e5 100644 --- a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts +++ b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts @@ -121,6 +121,24 @@ export function lowerStringDecoderMethodCall( loc, }; } + // A chunk that crossed the checked-dynamic boundary (a stream "data" + // payload): the runtime answers Node's write — strings pass through + // unchanged, ArrayBufferViews decode, anything else throws + // ERR_INVALID_ARG_TYPE. + if (chunk.type.kind === "dyn") { + return { + kind: "call", + callee: stringDecoderHelper( + lowerer, + name === "end" ? "endDyn" : "writeDyn", + receiver.type.shapeId, + loc, + ), + args: [receiver, chunk], + type: STRING, + loc, + }; + } chunk = checkedOptionalBuiltinArm(lowerer, chunk, BYTES_U8) ?? chunk; if (chunk.type.kind === "bytes" && chunk.type.elem !== "u8") { chunk = { @@ -153,7 +171,7 @@ export function lowerStringDecoderMethodCall( * the pure strdec.* libCalls. */ function stringDecoderHelper( lowerer: Lowerer, - op: "write" | "end" | "endChunk" | "endString" | "writeString", + op: "write" | "end" | "endChunk" | "endString" | "writeString" | "writeDyn" | "endDyn", shapeId: string, loc: SrcLoc, ): string { @@ -245,6 +263,76 @@ function stringDecoderHelper( { kind: "return", value: varRef("s.0", STRING, loc), loc }, ]; } + if (op === "writeDyn" || op === "endDyn") { + const dynChunk = { localId: "chunk.0", name: "chunk", type: DYN }; + params.push(dynChunk); + locals.splice(1, 0, { id: "chunk.0", name: "chunk", type: DYN, mutable: false }); + const writeDyn: IrExpr = { + kind: "libCall", + fn: "strdec.writeDyn", + args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)], + type: STRING, + loc, + }; + body = [ + { kind: "varDecl", localId: "s.0", init: writeDyn, loc }, + { + kind: "recordSet", + obj: varRef("d.0", recT, loc), + shapeId, + field: "%pending", + value: { + kind: "libCall", + fn: "strdec.nextDyn", + args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)], + type: F64, + loc, + }, + loc, + }, + { kind: "return", value: varRef("s.0", STRING, loc), loc }, + ]; + if (op === "endDyn") { + // end(buf) is write(buf) followed by the flush of whatever the write + // left buffered (Node's end). + locals.push({ id: "tail.0", name: "tail", type: STRING, mutable: false }); + body.splice( + body.length - 1, + 0, + { + kind: "varDecl", + localId: "tail.0", + init: { + kind: "libCall", + fn: "strdec.end", + args: [encRead(), pendingRead()], + type: STRING, + loc, + }, + loc, + }, + { + kind: "recordSet", + obj: varRef("d.0", recT, loc), + shapeId, + field: "%pending", + value: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + ); + body[body.length - 1] = { + kind: "return", + value: { + kind: "strConcat", + left: varRef("s.0", STRING, loc), + right: varRef("tail.0", STRING, loc), + type: STRING, + loc, + }, + loc, + }; + } + } if (op === "endString" || op === "writeString") { const inputT = STRING; params.push({ localId: "chunk.0", name: "chunk", type: inputT }); diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 8f8fa9be1..043297e46 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -91,6 +91,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "child.onDyn", "text.decoderEncoding", "strdec.encoding", + "strdec.writeDyn", + "strdec.nextDyn", "dyn.construct", "dyn.reflectApply", "dyn.reflectGet", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 4b64f12e1..f0a5409b1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4901,6 +4901,11 @@ function buildLibFnSignatures() { * packed NEW pending; end: (enc, pending) → the buffered partial's * flush (+1). Node-exact per encoding (oracle-pinned); none throws. */ "strdec.write": { argTypes: [STRING, F64, BYTES_U8], result: STRING }, + /** The checked-dynamic chunk forms (a stream "data" payload): a string + * passes through unchanged without touching the buffered state, an + * ArrayBufferView decodes, anything else throws ERR_INVALID_ARG_TYPE. */ + "strdec.writeDyn": { argTypes: [STRING, F64, DYN], result: STRING }, + "strdec.nextDyn": { argTypes: [STRING, F64, DYN], result: F64 }, "strdec.next": { argTypes: [STRING, F64, BYTES_U8], result: F64 }, "strdec.end": { argTypes: [STRING, F64], result: STRING }, /** A runtime encoding argument's canonical name (Node's normalizeEncoding: diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json new file mode 100644 index 000000000..4d1ce9721 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8428-string-decoder-dynamic-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8428-string-decoder-dynamic-chunks.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 6bb56195a..3e7277dc7 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1509,6 +1509,45 @@ double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk) return hold; } +/* The bytes view of a checked-dynamic write chunk, or NULL for a string + * chunk (Node's write returns it unchanged, never touching the decoder). + * Anything that is not a string or an ArrayBufferView throws Node's + * ERR_INVALID_ARG_TYPE for "buf". */ +static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { + *is_string = chunk->kind == SCR_DYN_STR; + if (*is_string) return NULL; + if ( + chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || + scr_buffer_storage_is(chunk)) { + return scr_bytes_buffer_source(chunk); + } + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); + return NULL; +} + +/* decoder.write(chunk) where the chunk crossed the checked-dynamic + * boundary (`stream.on("data", (data) => decoder.write(data))`). */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return scr_str_retain(chunk->v.str); + if (!view) return NULL; + ScrStr *out = scr_strdec_write(enc, pending, view); + scr_bytes_release(view); + return out; +} + +/* The decoder state after that write: a string chunk leaves it alone. */ +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return pending; + if (!view) return 0; + double hold = scr_strdec_next(enc, pending, view); + scr_bytes_release(view); + return hold; +} + /* decoder.end(): the buffered partial flushes — utf8's truncated sequence * becomes its replacement chars (the maximal-subpart rule), base64's * remainder encodes (padded; unpadded for base64url), utf16le's held diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4b9174bc0..8929e1a56 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -155,6 +155,11 @@ double scr_bytes_to_u8_clamp(double value); * incomplete-tail walk, base64's mod-3 groups, utf16le's odd-byte and * trailing-lead-surrogate holds. None throws. */ ScrStr *scr_strdec_write(const ScrStr *enc, double pending, const ScrBytes *chunk); +/* The checked-dynamic chunk forms: a string passes through unchanged (and + * leaves the buffered state alone); an ArrayBufferView decodes; anything + * else throws Node's ERR_INVALID_ARG_TYPE. */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk); +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk); double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk); ScrStr *scr_strdec_end(const ScrStr *enc, double pending); ScrStr *scr_strdec_encoding(const ScrDyn *enc); /* +1, or NULL with ERR_UNKNOWN_ENCODING pending */ diff --git a/tests/corpus/8428-string-decoder-dynamic-chunks.ts b/tests/corpus/8428-string-decoder-dynamic-chunks.ts new file mode 100644 index 000000000..6f97f1079 --- /dev/null +++ b/tests/corpus/8428-string-decoder-dynamic-chunks.ts @@ -0,0 +1,46 @@ +// StringDecoder.write over a chunk that crossed the checked-dynamic +// boundary: a string passes through unchanged, an ArrayBufferView decodes, +// and anything else throws Node's ERR_INVALID_ARG_TYPE. The decoder state +// survives an interleaved string chunk. +import { StringDecoder } from "node:string_decoder"; + +// The chunk arrives untyped (a stream "data" payload), like pi's exec.ts. +function write(decoder: StringDecoder, chunk: any): string { + return decoder.write(chunk); +} + +const decoder = new StringDecoder("utf8"); +console.log(JSON.stringify(write(decoder, "plain"))); +console.log(JSON.stringify(write(decoder, Buffer.from([0x61, 0xe2])))); +console.log(JSON.stringify(write(decoder, Buffer.from([0x82, 0xac, 0x62])))); +console.log(JSON.stringify(write(decoder, "mid"))); +try { + write(decoder, 123); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +try { + write(decoder, {}); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +const ender = new StringDecoder("base64"); +console.log(JSON.stringify(write(ender, Buffer.from([0x66])))); +console.log(JSON.stringify(ender.end())); +// end(chunk) is write(chunk) followed by the flush, over the same dynamic +// chunk shapes. +function endWith(decoder: StringDecoder, chunk: any): string { + return decoder.end(chunk); +} +const tail = new StringDecoder("utf8"); +console.log(JSON.stringify(write(tail, "x"))); +console.log(JSON.stringify(endWith(tail, Buffer.from([0xe2, 0x82])))); +console.log(JSON.stringify(endWith(new StringDecoder("utf8"), "mid"))); +try { + endWith(new StringDecoder("utf8"), 123); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} diff --git a/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json b/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json new file mode 100644 index 000000000..5c6c747fe --- /dev/null +++ b/tests/harness/corpus-costs/8428-string-decoder-dynamic-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 2575, "san": 3939 } From 26f15896eb7ca9e5184f846d13ca60e28f1721bf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:09:44 -0700 Subject: [PATCH 13/15] reflection: keep a record's static key walk for getOwnPropertyNames Object.getOwnPropertyNames over a record lowered through the checked-dynamic boxing this wave added for arrays and class instances, which linked the dynamic tier into a fully typed record walk and changed a program's binary for no semantic gain. The static key walk is exactly Object.keys' construction, so a record keeps it; the descriptor and symbol forms have no static walk and still take the dyn view, as do arrays and class instances. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lower-calls.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fa7afada4..fe35f8507 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7885,9 +7885,15 @@ function lowerObjectStaticCall( isUnitType(object.type) ) object = { kind: "dynFrom", value: object, type: DYN, loc: locOf(call) }; - // A record, array or class instance reflects through its own - // checked-dynamic view (own fields and elements, in property order). - if (object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) + // An array or class instance reflects through its own checked-dynamic + // view (own fields and elements, in property order). A record keeps its + // static key walk below when it has one — `getOwnPropertyNames` over a + // fixed record is exactly Object.keys' construction, and boxing a typed + // record would link the dynamic tier into a program that needs none of + // it. The descriptor and symbol forms have no static walk, so a record + // takes the view there. + const staticWalk = member === "getOwnPropertyNames" && object.type.kind === "record"; + if (!staticWalk && object.type.kind !== "dyn" && lowerer.dynConvertible(object.type)) object = lowerer.coerceToExpected(object, DYN); if (object.type.kind === "dyn") return { From 9172fecf191ccf932fcc2bb334518c6d014c445c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:09:49 -0700 Subject: [PATCH 14/15] runtime: link the wave's new entries only into the programs that use them Every new runtime entry this wave added landed in a unit that baseline programs link (scr_lib.c's write/copyFile flags, scr_bytes.c's string_decoder dyn forms, scr_events.c's prepend registries and stdin.isRaw, scr_async_dyn.c's uncaught prepend, scr_net.c's server.off). A changed unit changes its object's build-id and symbol metadata even when every retained function is byte-identical, so 1517 corpus programs reported a runtime change for code they never call. Each new entry now lives where only a program that uses it can reach it: - writeFile's `flag` option and the fs/promises twins move to scr_fs_flags.c (feature fsFlags); copyFile's mode form reuses scr_fs_cp.c's reflink walk, so it is that unit's SCR_FS_FLAGS build. - string_decoder's checked-dynamic chunk forms move to scr_bytes_strdec_dyn.c (feature strdecDyn). - the prepend registrations, stdin.isRaw and server.off are the SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and the SCR_NET_SERVER_OFF build of scr_net.c (feature processExtras). The prepend entry points reuse the append registration and move the new entry to the front, so no existing function changes and every default object stays byte-identical. The Windows copyFile arm stays beside the Win32 path helpers in scr_lib.c, inside its _WIN32 block. The source build (the -O1 -fsanitize=address lane) compiles these units from their sources rather than from the prebuilt pack, so RUNTIME_DEFINES names the same three builds the pack matrix selects. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 3 + .../src/backend/native-link-info-core.ts | 9 ++ .../compiler/src/backend/native/contracts.ts | 9 ++ .../compiler/src/backend/native/executable.ts | 12 ++ .../src/executable/executable-cache.ts | 3 + packages/compiler/src/index.ts | 3 + packages/compiler/src/ir/runtime-features.ts | 30 +++++ .../runtime-pack-matrix.mjs | 60 ++++++++++ packages/runtime/src/scr_async.c | 10 -- packages/runtime/src/scr_async_dyn.c | 49 +++------ packages/runtime/src/scr_bytes.c | 39 ------- packages/runtime/src/scr_bytes_strdec_dyn.c | 50 +++++++++ packages/runtime/src/scr_events.c | 103 +++++++++--------- packages/runtime/src/scr_fs_cp.c | 12 +- packages/runtime/src/scr_fs_flags.c | 102 +++++++++++++++++ packages/runtime/src/scr_lib.c | 46 +++----- packages/runtime/src/scr_net.c | 6 +- 17 files changed, 380 insertions(+), 166 deletions(-) create mode 100644 packages/runtime/src/scr_bytes_strdec_dyn.c create mode 100644 packages/runtime/src/scr_fs_flags.c diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..0fdc92436 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,9 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.fsFlags ? { fsFlags: true } : {}), + ...(features.strdecDyn ? { strdecDyn: true } : {}), + ...(features.processExtras ? { processExtras: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..a76aea1c8 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,15 @@ export interface NativeLinkFeatures { /** The program uses process.stdin.setEncoding, string 'data' listeners * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ stdioExtended?: boolean; + /** writeFile's `flag` disposition or copyFile's mode argument: + * scr_fs_flags.c and the SCR_FS_FLAGS build of scr_fs_cp.c. */ + fsFlags?: boolean; + /** string_decoder's checked-dynamic chunk forms: scr_bytes_strdec_dyn.c. */ + strdecDyn?: boolean; + /** process.prependListener, stdin.isRaw or server.off: the + * SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and the + * SCR_NET_SERVER_OFF build of scr_net.c. */ + processExtras?: boolean; /** Calls into Node.js (@scriptc/node): scr_node_island.c and the * SCR_NODE_ISLANDS variant of the event loop unit. */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index f093d6483..522b355b2 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -161,6 +161,15 @@ export interface CcOptions { warningReplace?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; + /** SCR_FS_FLAGS: writeFile's `flag` option and copyFile's mode argument + * (scr_fs_flags.c and that build of scr_fs_cp.c). */ + fsFlags?: boolean; + /** string_decoder's checked-dynamic chunk forms: compiles + * scr_bytes_strdec_dyn.c. */ + strdecDyn?: boolean; + /** SCR_PROCESS_EXTRAS / SCR_NET_SERVER_OFF: process.prependListener, + * stdin.isRaw and server.off. */ + processExtras?: boolean; /** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and * the event loop with SCR_NODE_ISLANDS (its helper slot). */ nodeIsland?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 01efc7e34..8d8979185 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"), + // writeFile's `flag` option and the fs/promises twins; the copyFile mode + // argument is the SCR_FS_FLAGS build of scr_fs_cp.c. + unit((u) => u.opts.fsFlags === true, "scr_fs_flags.c"), + // string_decoder's checked-dynamic chunk forms, beside their typed twins. + unit((u) => u.opts.strdecDyn === true, "scr_bytes_strdec_dyn.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, @@ -412,6 +417,13 @@ const RUNTIME_DEFINES: readonly { { define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true }, { define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace }, { define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended }, + // The wave's prepend registrations and stdin.isRaw (scr_events.c, + // scr_async_dyn.c), server.off (scr_net.c) and copyFile's mode form + // (scr_fs_cp.c). The source build compiles these units from source, so the + // defines must name the same builds runtime-pack-matrix.mjs selects. + { define: "SCR_PROCESS_EXTRAS", when: (opts) => opts.processExtras }, + { define: "SCR_NET_SERVER_OFF", when: (opts) => opts.processExtras }, + { define: "SCR_FS_FLAGS", when: (opts) => opts.fsFlags }, // scr_async.c's native promise reactions, which the web streams unit uses, // scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes, // and scr_fetch.c's bodies of its streams. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..141fad738 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -65,6 +65,9 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; stdioExtended?: boolean; + fsFlags?: boolean; + strdecDyn?: boolean; + processExtras?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..c9ca2ffa9 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -700,6 +700,9 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.fsFlags ? { fsFlags: true } : {}), + ...(features.strdecDyn ? { strdecDyn: true } : {}), + ...(features.processExtras ? { processExtras: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 2c9f6d57f..f0bb7bea6 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1248,6 +1248,33 @@ const RUNTIME_FEATURE_RULES = { "tls.sockGetCertificate", ], }), + /** writeFile's `flag` disposition ("w" / "wx") and copyFile's mode + * argument: scr_fs_flags.c and the SCR_FS_FLAGS build of scr_fs_cp.c. + * Programs that never pass either keep both units' default objects. */ + fsFlags: rule({ + fns: [ + "fs.copyFileFlagsSync", + "fs.writeFileFlagsSync", + "fsp.copyFileFlags", + "fsp.writeFileFlags", + ], + }), + /** string_decoder's checked-dynamic chunk forms (a stream "data" payload): + * scr_bytes_strdec_dyn.c. */ + strdecDyn: rule({ fns: ["strdec.nextDyn", "strdec.writeDyn"] }), + /** The process and stream event registries' extras — prependListener for + * signals, 'exit' and uncaught exceptions, stdin.isRaw, and server.off: + * the SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and + * the SCR_NET_SERVER_OFF build of scr_net.c. */ + processExtras: rule({ + fns: [ + "net.serverOff", + "process.prependExit", + "process.prependSignal", + "process.prependUncaughtException", + "process.stdinIsRaw", + ], + }), zlib: rule({ prefixes: ["zlib."] }), /** node:zlib's Brotli forms: zlib.brotliInstall, which %main calls when * the program uses Brotli (scr_brotli.c). */ @@ -1289,6 +1316,7 @@ function noRuntimeFeatures(): RuntimeFeatures { eventsOnceSignal: false, fetch: false, fileHandle: false, + fsFlags: false, fsWatch: false, http2: false, http: false, @@ -1308,6 +1336,7 @@ function noRuntimeFeatures(): RuntimeFeatures { pki: false, ed25519: false, processEvents: false, + processExtras: false, qs: false, regex: false, searchParams: false, @@ -1316,6 +1345,7 @@ function noRuntimeFeatures(): RuntimeFeatures { moduleNamespace: false, moduleNamespaceDyn: false, stream: false, + strdecDyn: false, streamDyn: false, handleProps: false, childLifecycle: false, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index d83d8393c..478ee5d24 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -62,6 +62,12 @@ export const BASE_RUNTIME_SOURCES = [ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], + // writeFile's `flag` disposition and the fs/promises twins; the copyFile + // mode form is the SCR_FS_FLAGS build of scr_fs_cp.c. + ["scr_fs_flags.c", "fsFlags"], + // string_decoder's checked-dynamic chunk forms, apart from scr_bytes.c's + // typed twins so a typed program keeps that object byte for byte. + ["scr_bytes_strdec_dyn.c", "strdecDyn"], ["scr_regex.c", "regex"], ["scr_glob.c", "regex"], ["scr_idna.c", "regex"], @@ -278,6 +284,37 @@ export function variantsFor(source) { when: { warningReplace: true }, defines: ["SCR_WARNING_REPLACE"], }); + // The process-prepend registries (processExtras) add + // scr_process_prepend_uncaught_exception, in a build of its own so the + // default object stays put for every program. + variants.push({ + id: "process-extras", + when: { processExtras: true }, + defines: ["SCR_PROCESS_EXTRAS"], + }); + variants.push({ + id: "warning-replace-process-extras", + when: { warningReplace: true, processExtras: true }, + defines: ["SCR_WARNING_REPLACE", "SCR_PROCESS_EXTRAS"], + }); + } + // copyFileSync's mode argument reuses scr_fs_cp.c's reflink walk, so the + // entry lives in a build of this unit rather than a unit of its own. + if (source === "scr_fs_cp.c") { + variants.push({ + id: "flags", + when: { fsFlags: true }, + defines: ["SCR_FS_FLAGS"], + }); + } + // server.off / removeListener: the listener-removal entry, in a build of + // its own so a program that never removes a listener keeps the default. + if (source === "scr_net.c") { + variants.push({ + id: "server-off", + when: { processExtras: true }, + defines: ["SCR_NET_SERVER_OFF"], + }); } // Programs with web streams link the promise unit with native promise // reactions, the builtin-class unit whose instanceof knows their classes @@ -331,6 +368,29 @@ export function variantsFor(source) { when: { stdioExtended: true, nodeIsland: true }, defines: ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], }); + // processExtras adds the prepend registrations and stdin's raw-mode + // flag; the combinations above then need their own builds, since the + // variant with the most conditions is the one selected. + variants.push({ + id: "process-extras", + when: { processExtras: true }, + defines: ["SCR_PROCESS_EXTRAS"], + }); + variants.push({ + id: "process-extras-stdio-extended", + when: { processExtras: true, stdioExtended: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_STDIO_EXTENDED"], + }); + variants.push({ + id: "process-extras-node-island", + when: { processExtras: true, nodeIsland: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_NODE_ISLANDS"], + }); + variants.push({ + id: "process-extras-stdio-extended-node-island", + when: { processExtras: true, stdioExtended: true, nodeIsland: true }, + defines: ["SCR_PROCESS_EXTRAS", "SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], + }); } // Programs with Node islands link the event loop with the Node island slot; // every other program keeps the default object, byte for byte. diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index f26d520f9..0225269f4 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3234,11 +3234,6 @@ ScrPromise *scr_fsp_write_file_mode(ScrStr *path, ScrStr *data, double mode) { return scr_promise_settled_void(); } -ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { - scr_fs_write_file_flags(path, data, mode, exclusive); - return scr_promise_settled_void(); -} - ScrPromise *scr_fsp_mkdir(ScrStr *path) { scr_fs_mkdir(path); return scr_promise_settled_void(); @@ -3299,11 +3294,6 @@ ScrPromise *scr_fsp_copyfile(ScrStr *src, ScrStr *dest) { return scr_promise_settled_void(); } -ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { - scr_fs_copyfile_flags(src, dest, mode); - return scr_promise_settled_void(); -} - /* readFile(path, encoding | { encoding }): the encoding is checked first, * so an unknown name rejects before the read. */ ScrPromise *scr_fsp_read_file_encoded(ScrStr *path, ScrStr *encoding) { diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 99c17eeca..6ad771b17 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -341,11 +341,7 @@ static bool scr_rej_check_listener(ScrDyn *fn) { return false; } -/* The registration body: `prepend` puts the listener in FRONT of the - * registry's existing entries (process.prependListener — Node's - * EventEmitter order), the plain form appends. */ -static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once, - bool prepend) { +static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { if (*n == *cap) { *cap = *cap ? *cap * 2 : 4; *list = realloc(*list, *cap * sizeof **list); @@ -354,18 +350,7 @@ static void scr_rej_push_at(ScrRejListener **list, size_t *n, size_t *cap, ScrDy ScrRejState *state = calloc(1, sizeof *state); if (!state) scr_ad_oom(); state->refs = 1; - ScrRejListener entry = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; - if (prepend) { - memmove(*list + 1, *list, *n * sizeof **list); - (*list)[0] = entry; - (*n)++; - } else { - (*list)[(*n)++] = entry; - } -} - -static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn *fn, bool once) { - scr_rej_push_at(list, n, cap, fn, once, false); + (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; } static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) { @@ -482,32 +467,32 @@ static void scr_uncaught_teardown(void) { scr_uncaught_sync_hook(); } -/* Arm the registry's exit-time teardown once, whichever entry point - * registers first. */ -static void scr_uncaught_arm(void) { - static SCR_TL bool armed = false; - if (armed) return; - armed = true; - scr_atexit(scr_uncaught_teardown); -} - void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor) { if (!scr_rej_check_listener(fn)) return; - scr_uncaught_arm(); + static SCR_TL bool armed = false; + if (!armed) { armed = true; scr_atexit(scr_uncaught_teardown); } size_t k = monitor ? 1 : 0; scr_rej_push(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, once); scr_uncaught_sync_hook(); } +#ifdef SCR_PROCESS_EXTRAS /* process.prependListener("uncaughtException"[Monitor], fn): the same - * listener in front of the registry's existing entries. */ + * listener in FRONT of the registry's existing entries (Node's + * EventEmitter order). The append path above registers it and the new entry + * then moves to the front, so the two share one registration; a refused + * listener (a non-function) left the list alone and nothing moves. */ void scr_process_prepend_uncaught_exception(ScrDyn *fn, bool monitor) { - if (!scr_rej_check_listener(fn)) return; - scr_uncaught_arm(); size_t k = monitor ? 1 : 0; - scr_rej_push_at(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, false, true); - scr_uncaught_sync_hook(); + size_t before = scr_uncaught_n[k]; + scr_process_on_uncaught_exception(fn, false, monitor); + if (scr_uncaught_n[k] != before + 1) return; + ScrRejListener entry = scr_uncaught_ls[k][scr_uncaught_n[k] - 1]; + memmove(scr_uncaught_ls[k] + 1, scr_uncaught_ls[k], + (scr_uncaught_n[k] - 1) * sizeof *scr_uncaught_ls[k]); + scr_uncaught_ls[k][0] = entry; } +#endif void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { if (!scr_rej_check_listener(fn)) return; diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 3e7277dc7..6bb56195a 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -1509,45 +1509,6 @@ double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk) return hold; } -/* The bytes view of a checked-dynamic write chunk, or NULL for a string - * chunk (Node's write returns it unchanged, never touching the decoder). - * Anything that is not a string or an ArrayBufferView throws Node's - * ERR_INVALID_ARG_TYPE for "buf". */ -static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { - *is_string = chunk->kind == SCR_DYN_STR; - if (*is_string) return NULL; - if ( - chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || - scr_buffer_storage_is(chunk)) { - return scr_bytes_buffer_source(chunk); - } - scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); - return NULL; -} - -/* decoder.write(chunk) where the chunk crossed the checked-dynamic - * boundary (`stream.on("data", (data) => decoder.write(data))`). */ -ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { - bool is_string = false; - ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); - if (is_string) return scr_str_retain(chunk->v.str); - if (!view) return NULL; - ScrStr *out = scr_strdec_write(enc, pending, view); - scr_bytes_release(view); - return out; -} - -/* The decoder state after that write: a string chunk leaves it alone. */ -double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { - bool is_string = false; - ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); - if (is_string) return pending; - if (!view) return 0; - double hold = scr_strdec_next(enc, pending, view); - scr_bytes_release(view); - return hold; -} - /* decoder.end(): the buffered partial flushes — utf8's truncated sequence * becomes its replacement chars (the maximal-subpart rule), base64's * remainder encodes (padded; unpadded for base64url), utf16le's held diff --git a/packages/runtime/src/scr_bytes_strdec_dyn.c b/packages/runtime/src/scr_bytes_strdec_dyn.c new file mode 100644 index 000000000..1c9bd2998 --- /dev/null +++ b/packages/runtime/src/scr_bytes_strdec_dyn.c @@ -0,0 +1,50 @@ +/* string_decoder's checked-dynamic chunk forms. + * + * A stream's "data" payload crosses the checked-dynamic boundary, so + * decoder.write and decoder.end receive it as an ScrDyn: a string passes + * through unchanged without touching the buffered state, an ArrayBufferView + * (Buffer, TypedArray, DataView) decodes through scr_bytes.c's per-encoding + * helpers with the packed-f64 pending state, and anything else throws Node's + * ERR_INVALID_ARG_TYPE for "buf". They live in their own unit because the + * static twins in scr_bytes.c belong only to programs whose chunks are + * already typed; those programs keep that object byte for byte. */ +#include "scr_runtime.h" + +/* The bytes view of a checked-dynamic write chunk, or NULL for a string + * chunk (Node's write returns it unchanged, never touching the decoder). + * Anything that is not a string or an ArrayBufferView throws Node's + * ERR_INVALID_ARG_TYPE for "buf". */ +static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { + *is_string = chunk->kind == SCR_DYN_STR; + if (*is_string) return NULL; + if ( + chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || + scr_buffer_storage_is(chunk)) { + return scr_bytes_buffer_source(chunk); + } + scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); + return NULL; +} + +/* decoder.write(chunk) where the chunk crossed the checked-dynamic + * boundary (`stream.on("data", (data) => decoder.write(data))`). */ +ScrStr *scr_strdec_write_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return scr_str_retain(chunk->v.str); + if (!view) return NULL; + ScrStr *out = scr_strdec_write(enc, pending, view); + scr_bytes_release(view); + return out; +} + +/* The decoder state after that write: a string chunk leaves it alone. */ +double scr_strdec_next_dyn(const ScrStr *enc, double pending, const ScrDyn *chunk) { + bool is_string = false; + ScrBytes *view = scr_strdec_dyn_view(chunk, &is_string); + if (is_string) return pending; + if (!view) return 0; + double hold = scr_strdec_next(enc, pending, view); + scr_bytes_release(view); + return hold; +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index a159c2fdc..aa550a5f3 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -192,10 +192,7 @@ static bool scr_signal_check(ScrStr *name, ScrDyn *cb) { return true; } -/* The registration body both entry points share: `prepend` puts the - * listener in FRONT of the signal's existing ones (process.prependListener - * — Node's EventEmitter order), the plain form appends. */ -static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { if (!scr_signal_check(name, cb)) return; if (!scr_context_is_main()) return; #ifdef __wasi__ @@ -245,25 +242,37 @@ static void scr_signal_add(ScrStr *name, ScrDyn *cb, bool once, bool prepend) { reg->ls = realloc(reg->ls, reg->cap * sizeof *reg->ls); if (!reg->ls) scr_events_oom(); } - ScrSigListener entry = - (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; - if (prepend) { - memmove(reg->ls + 1, reg->ls, reg->n * sizeof *reg->ls); - reg->ls[0] = entry; - reg->n++; - } else { - reg->ls[reg->n++] = entry; - } + reg->ls[reg->n++] = (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; #endif } -void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { - scr_signal_add(name, cb, once, false); +#ifdef SCR_PROCESS_EXTRAS +/* process.prependListener(, cb): the same listener in FRONT of the + * signal's existing registrations (Node's EventEmitter order). The append + * path above registers it and the new entry then moves to the front, so the + * two share one registration; a refused or inert registration (a throw, a + * non-main context) left the list alone and nothing moves. */ +void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { +#ifdef __wasi__ + scr_signal_on(name, cb, false); /* traps, exactly as the append form does */ +#else + int sig = scr_signal_from_name(name); + if (sig < 0) sig = 0; + ScrSigReg *reg = scr_sig_regs; + while (reg && reg->sig != sig) reg = reg->next; + size_t before = reg ? reg->n : 0; + scr_signal_on(name, cb, false); + if (!reg) { + reg = scr_sig_regs; + while (reg && reg->sig != sig) reg = reg->next; + } + if (!reg || reg->n != before + 1) return; + ScrSigListener entry = reg->ls[reg->n - 1]; + memmove(reg->ls + 1, reg->ls, (reg->n - 1) * sizeof *reg->ls); + reg->ls[0] = entry; +#endif } - -/* process.prependListener(, cb): the same listener in front of the - * signal's existing registrations. */ -void scr_signal_prepend(ScrStr *name, ScrDyn *cb) { scr_signal_add(name, cb, false, true); } +#endif static void scr_sig_reg_drop(ScrSigReg *reg) { if (reg->sig > 0) { @@ -1708,6 +1717,31 @@ void scr_process_on_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, doub scr_exit_n++; } +#ifdef SCR_PROCESS_EXTRAS +/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a + * pipe-backed stdin has no such flag and answers Node's undefined, which + * the emitter's union construction spells -1. */ +double scr_process_stdin_is_raw(void) { + if (!scr_context_is_main()) return -1; + if (!scr_process_is_tty(0)) return -1; + return scr_stdio_raw ? 1.0 : 0.0; +} + +/* process.prependListener("exit", cb): the same listener in FRONT of the + * registry's existing entries (Node's EventEmitter order). The append path + * above registers it and the new entry then moves to the front, so the two + * share one registration; a registry already swept at exit released the + * closure and left the list empty, and nothing moves. */ +void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { + size_t before = scr_exit_n; + scr_process_on_exit(cb, fn, false); + if (scr_exit_n != before + 1) return; + ScrExitListener entry = scr_exit_ls[scr_exit_n - 1]; + memmove(scr_exit_ls + 1, scr_exit_ls, (scr_exit_n - 1) * sizeof *scr_exit_ls); + scr_exit_ls[0] = entry; +} +#endif + #ifdef SCR_NODE_ISLANDS /* Island code's 'exit' listeners, at their registration position among * the program's own. */ @@ -1734,37 +1768,6 @@ void scr_process_off_exit_island(uint32_t island) { } #endif -/* process.stdin.isRaw: the raw-mode flag of a TTY stdin (1/0); a - * pipe-backed stdin has no such flag and answers Node's undefined, which - * the emitter's union construction spells -1. */ -double scr_process_stdin_is_raw(void) { - if (!scr_context_is_main()) return -1; - if (!scr_process_is_tty(0)) return -1; - return scr_stdio_raw ? 1.0 : 0.0; -} - -/* process.prependListener("exit", cb): the same listener in front of the - * registry's existing entries. */ -void scr_process_prepend_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, double)) { - if (scr_exit_ran) { - scr_closure_release(cb); - return; - } - if (scr_exit_n == scr_exit_cap) { - scr_exit_cap = scr_exit_cap ? scr_exit_cap * 2 : 2; - scr_exit_ls = realloc(scr_exit_ls, scr_exit_cap * sizeof *scr_exit_ls); - if (!scr_exit_ls) scr_events_oom(); - } - memmove(scr_exit_ls + 1, scr_exit_ls, scr_exit_n * sizeof *scr_exit_ls); - scr_exit_ls[0].cb = cb; - scr_exit_ls[0].fn = fn; - scr_exit_ls[0].once = false; -#ifdef SCR_NODE_ISLANDS - scr_exit_ls[0].island = 0; -#endif - scr_exit_n++; -} - void scr_process_off_exit(ScrClosure *cb /*borrowed*/) { for (size_t i = 0; i < scr_exit_n; i++) { #ifdef SCR_NODE_ISLANDS diff --git a/packages/runtime/src/scr_fs_cp.c b/packages/runtime/src/scr_fs_cp.c index f6c1fd13b..8c49dddb2 100644 --- a/packages/runtime/src/scr_fs_cp.c +++ b/packages/runtime/src/scr_fs_cp.c @@ -589,14 +589,19 @@ static void cp_copyfile(const ScrStr *src, const ScrStr *dest, int mode, const c if (e) scr_fs_throw2(e, syscall, src, dest); } +#if defined(SCR_FS_FLAGS) && !defined(_WIN32) /* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's * uv_fs_copyfile with Node's flag bits (the C++ binding's range check and * message: any value in [0, 7], truncated to an integer — 1.5 is EXCL). * COPYFILE_EXCL (1) fails EEXIST on an existing destination, * COPYFILE_FICLONE (2) requests a reflink and falls back to a copy, and - * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. This file - * excludes _WIN32, whose arm (CopyFileW and its ENOSYS for FICLONE_FORCE) - * lives in scr_lib.c beside the Win32 fs helpers. */ + * COPYFILE_FICLONE_FORCE (4) fails where no reflink is possible. + * + * This is the SCR_FS_FLAGS build of the unit: cp_copyfile is the reflink + * walk, and keeping the entry behind a define leaves every program that + * never passes a mode with the default object, byte for byte. The Windows + * arm (CopyFileW and its ENOSYS for FICLONE_FORCE) sits beside the Win32 fs + * helpers in scr_lib.c. */ void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { if (!(mode >= 0 && mode <= 7)) { static const char text[] = "mode is out of range: >= 0 && <= 7"; @@ -607,6 +612,7 @@ void scr_fs_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { if (!scr_fs_path_ok(dest, "dest")) return; cp_copyfile(src, dest, (int)mode, "copyfile"); } +#endif /* utimes(dest, atime, mtime) in seconds, through the utimesSync path. */ static bool cp_utime(const ScrStr *dest, double atime, double mtime) { diff --git a/packages/runtime/src/scr_fs_flags.c b/packages/runtime/src/scr_fs_flags.c new file mode 100644 index 000000000..29f3e07d6 --- /dev/null +++ b/packages/runtime/src/scr_fs_flags.c @@ -0,0 +1,102 @@ +/* fs.writeFile's `flag` option ("w", "wx") and the fs/promises twins of the + * flag-aware write and copy forms. + * + * The write is scr_lib.c's scr_fs_write_file_mode body with the disposition + * flag added (the same portable open(2) calls, so it builds for every + * target). It lives in this unit rather than beside that function so a + * program that never passes `flag` keeps scr_lib.c's object byte for byte; + * the fs.copyFile mode form rides scr_fs_cp.c's SCR_FS_FLAGS build, whose + * reflink helper it reuses, and that form's Windows arm sits beside the + * Win32 path helpers in scr_lib.c. */ +#include "scr_runtime.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif + +/* O_BINARY: Windows-only (CRT text mode would translate \n on fd writes); + * zero everywhere else. scr_lib.c carries the same guard for its writes. */ +#ifndef O_BINARY +#define O_BINARY 0 +#endif + +/* Node's mode validation for the options-record write: an integer in + * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ +static bool scr_fs_write_mode_ok(double mode) { + char recv[48], msg[176]; + scr_num_received(mode, recv); + if (!(isfinite(mode) && trunc(mode) == mode)) { + int len = snprintf(msg, sizeof msg, + "The value of \"mode\" is out of range. It must be an integer. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + if (mode < 0 || mode > 4294967295.0) { + int len = snprintf(msg, sizeof msg, + "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; + } + return true; +} + +/* The open(2) write behind the flag form: O_TRUNC plus `extra` (only O_EXCL + * today), creation mode applying at CREATION only (umask applying) — an + * existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { + if (!scr_fs_path_ok(path, "path")) return; + /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating + * \n in these byte-exact writes (fopen's "wb" path already does). */ + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); + if (fd < 0) { + scr_fs_throw(errno, "open", path); + return; + } + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + ssize_t wrote = write(fd, bytes + at, data->len - at); + if (wrote < 0) { + if (errno == EINTR) continue; + int e = errno; + close(fd); + scr_str_utf8_end(owned); + scr_fs_throw(e, "write", path); + return; + } + at += (size_t)wrote; + } + scr_str_utf8_end(owned); + if (close(fd) != 0) scr_fs_throw(errno, "close", path); +} + +/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the + * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path + * exists); 0 is "w", the default truncate-or-create. */ +void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + if (!scr_fs_write_mode_ok(mode)) return; + scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); +} + +/* fs.promises.writeFile(path, data, { mode, flag }) and + * fs.promises.copyFile(src, dest, mode): the settled-promise twins of + * fs.writeFileFlagsSync and fs.copyFileFlagsSync. */ +ScrPromise *scr_fsp_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { + scr_fs_write_file_flags(path, data, mode, exclusive); + return scr_promise_settled_void(); +} + +ScrPromise *scr_fsp_copyfile_flags(ScrStr *src, ScrStr *dest, double mode) { + scr_fs_copyfile_flags(src, dest, mode); + return scr_promise_settled_void(); +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index d4d84a6e8..418c3bfe2 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -3531,9 +3531,12 @@ void scr_fs_write_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "wb"); } -/* Node's mode validation for the options-record write: an integer in - * open(2)'s mode_t range. Throws ERR_OUT_OF_RANGE and answers false. */ -static bool scr_fs_write_mode_ok(double mode) { +/* writeFileSync(p, data, { mode }): the mode is open(2)'s O_CREAT + * argument — it applies at CREATION only (umask applying), and an + * existing file keeps its permissions, exactly Node (which never chmods + * here). Same error shapes as the plain form. */ +void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { + if (!scr_fs_path_ok(path, "path")) return; char recv[48], msg[176]; scr_num_received(mode, recv); if (!(isfinite(mode) && trunc(mode) == mode)) { @@ -3541,27 +3544,18 @@ static bool scr_fs_write_mode_ok(double mode) { "The value of \"mode\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; + return; } if (mode < 0 || mode > 4294967295.0) { int len = snprintf(msg, sizeof msg, "The value of \"mode\" is out of range. It must be >= 0 && <= 4294967295. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return false; + return; } - return true; -} - -/* The open(2) write behind the mode/flag forms: O_TRUNC plus `extra` (only - * O_EXCL today), creation mode applying at CREATION only (umask applying) — - * an existing file keeps its permissions, exactly Node (which never chmods - * here). Same error shapes as the plain form. */ -static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int extra) { - if (!scr_fs_path_ok(path, "path")) return; /* O_BINARY: zero on POSIX; on Windows it keeps the CRT from translating * \n in these byte-exact writes (fopen's "wb" path already does). */ - int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | extra, mode); + int fd = open(path->data, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, (mode_t)mode); if (fd < 0) { scr_fs_throw(errno, "open", path); return; @@ -3585,22 +3579,6 @@ static void scr_fs_write_file_open(ScrStr *path, ScrStr *data, mode_t mode, int if (close(fd) != 0) scr_fs_throw(errno, "close", path); } -/* writeFileSync(p, data, { mode }). */ -void scr_fs_write_file_mode(ScrStr *path, ScrStr *data, double mode) { - if (!scr_fs_path_ok(path, "path")) return; - if (!scr_fs_write_mode_ok(mode)) return; - scr_fs_write_file_open(path, data, (mode_t)mode, 0); -} - -/* writeFileSync(p, data, { mode, flag }): Node's open(2) with the - * disposition flag. `exclusive` is "wx" (O_EXCL: EEXIST when the path - * exists); 0 is "w", the default truncate-or-create. */ -void scr_fs_write_file_flags(ScrStr *path, ScrStr *data, double mode, double exclusive) { - if (!scr_fs_path_ok(path, "path")) return; - if (!scr_fs_write_mode_ok(mode)) return; - scr_fs_write_file_open(path, data, (mode_t)mode, exclusive != 0 ? O_EXCL : 0); -} - void scr_fs_append_file(ScrStr *path, ScrStr *data) { scr_fs_write_common(path, data, "ab"); } @@ -4694,6 +4672,12 @@ static bool scr_fs_win_same_file(const WCHAR *a, const WCHAR *b) { first.nFileIndexLow == second.nFileIndexLow; } +/* The Win32 arm of copyFile's mode form (scr_fs_cp.c's SCR_FS_FLAGS build + * has the POSIX one; writeFile's flag form is portable and lives in + * scr_fs_flags.c). It sits here because the wide-path and errno helpers + * above are this file's; _WIN32 excludes it from the POSIX builds, so every + * other platform's scr_lib.c object is unchanged. */ + /* copyFileSync(src, dest, mode) and copyFile(src, dest, mode): libuv's * fs__copyfile in src/win/fs.c. CopyFileW carries only COPYFILE_EXCL (its * bFailIfExists flag); COPYFILE_FICLONE (2) falls back to a plain copy and diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index c17c8b004..477fd9ab7 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2438,9 +2438,12 @@ void scr_net_server_on_close(ScrNetServer *s, ScrClosure *cb /*moves*/, bool onc scr_net_ls_add(&s->close_ls, cb, NULL, once); } +#ifdef SCR_NET_SERVER_OFF /* server.off(event, cb) / removeListener: the LAST listener standing for * the same function leaves the event's list — Node's EventEmitter removes - * the most recently added match, so the scan runs back to front. */ + * the most recently added match, so the scan runs back to front. The + * SCR_NET_SERVER_OFF build of the unit carries it, so a program that never + * removes a listener keeps the default object byte for byte. */ void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kind) { ScrNetLs *l = kind == SCR_NET_OFF_ERROR ? &s->err_ls : kind == SCR_NET_OFF_CLOSE ? &s->close_ls @@ -2461,6 +2464,7 @@ void scr_net_server_off(ScrNetServer *s, ScrClosure *cb /*borrowed*/, double kin return; } } +#endif /* server.on('listening', cb): joins the deferred 'listening' list the * listen(port, cb) callback rides (the sweep fires it once after a From e36a442468f2a0e446a4b09e54d7d772c6c663ba Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:59:44 -0500 Subject: [PATCH 15/15] Throw for primitive Reflect.has targets, record isRaw, and match StringDecoder chunks Reflect.has over a target held as any, unknown or a union answered a presence result for a primitive. It now checks the target at run time after both arguments are evaluated and throws Node's TypeError ("Reflect.has called on non-object"). The stdlib-fence snapshot no longer lists Reflect.has, which lowers. process.stdin.isRaw read a flag that only the dynamic setRawMode path set, so a direct process.stdin.setRawMode(true) on a TTY left it false. The flag now lives with scr_process_stdin_set_raw_mode, which every path calls, readline's raw mode included. StringDecoder.end(chunk) with a dynamic chunk that is undefined threw ERR_INVALID_ARG_TYPE. It now only flushes, as Node's end skips the write for undefined. A dynamic ArrayBuffer or SharedArrayBuffer chunk decoded its contents; write and end now throw Node's ERR_INVALID_ARG_TYPE for it, as it is not an ArrayBufferView. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/builtins/reflection.ts | 47 ++++++++++++++++--- .../frontend/lowering/builtins/text-codecs.ts | 25 ++++++++++ packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 2 + packages/runtime/src/scr_bytes_strdec_dyn.c | 6 +-- packages/runtime/src/scr_dyn.h | 2 + packages/runtime/src/scr_events.c | 7 ++- packages/runtime/src/scr_json.c | 19 ++++++++ packages/runtime/src/scr_lib.c | 9 +++- packages/runtime/src/scr_lib.h | 2 + .../corpus/8426-reflect-object-reflections.ts | 19 ++++++++ .../8428-string-decoder-dynamic-chunks.ts | 25 ++++++++++ .../harness/__snapshots__/stdlib-fence.ts.txt | 9 ---- 14 files changed, 149 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 00e0e9bc5..7231e9820 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1151,6 +1151,7 @@ export const LIB_FN_SYMS: Record = { "dyn.keyDelete": "scr_dyn_key_delete", "dyn.keyDeleteComputed": "scr_dyn_key_delete_computed", "dyn.reflectDeleteProperty": "scr_dyn_reflect_delete_property", + "dyn.reflectHasTarget": "scr_dyn_reflect_has_target", "dyn.hasKeyComputed": "scr_dyn_has_key_computed", "dyn.hasOwnComputed": "scr_dyn_has_own_computed", "dyn.propertyIsEnumerableComputed": "scr_dyn_property_is_enumerable_computed", diff --git a/packages/compiler/src/frontend/lowering/builtins/reflection.ts b/packages/compiler/src/frontend/lowering/builtins/reflection.ts index 2988f9feb..b90c921c2 100644 --- a/packages/compiler/src/frontend/lowering/builtins/reflection.ts +++ b/packages/compiler/src/frontend/lowering/builtins/reflection.ts @@ -10,6 +10,7 @@ import { type IrExpr, STRING, type SrcLoc, + VOID, arrayOf, isUnitType, } from "../../../ir/ir.js"; @@ -65,8 +66,9 @@ export function lowerReflectCall( // Reflect.has(target, key) — the `in` operator's presence query over // the target's checked-dynamic view (own and inherited names, proxy // `has` traps included). A primitive target is Node's TypeError - // ("Reflect.has called on non-object"), which the presence query - // answers false for instead: fenced, never silently divergent. + // ("Reflect.has called on non-object"): a statically primitive target + // is refused, and a target held as any, unknown or a union is checked + // at run time (dyn.reflectHasTarget). if ( reflectMember === "has" && call.arguments.nodes.length === 2 && @@ -85,13 +87,44 @@ export function lowerReflectCall( ); if (!lowerer.dynConvertible(target.type)) lowerer.noLowering("Reflect.has on this target", call.arguments.nodes[0]!); + const key = lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN); + if (target.type.kind !== "dyn" && target.type.kind !== "union") { + return { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [lowerer.coerceToExpected(target, DYN), key], + type: BOOL, + loc, + }; + } + // A target held as any, unknown or a union may be a primitive at run + // time: both arguments are evaluated, then the target is checked. + const targetLocal = lowerer.declareHiddenLocal("%reflectHasTarget", DYN); + const keyLocal = lowerer.declareHiddenLocal("%reflectHasKey", DYN); + const targetRef = varRef(targetLocal.id, DYN, loc); return { - kind: "libCall", - fn: "dyn.hasKeyComputed", - args: [ - lowerer.coerceToExpected(target, DYN), - lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN), + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: targetLocal.id, + init: lowerer.coerceToExpected(target, DYN), + loc, + }, + { kind: "varDecl", localId: keyLocal.id, init: key, loc }, + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "dyn.reflectHasTarget", args: [targetRef], type: VOID, loc }, + loc, + }, ], + result: { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [targetRef, varRef(keyLocal.id, DYN, loc)], + type: BOOL, + loc, + }, type: BOOL, loc, }; diff --git a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts index 1100523e5..ae63652ed 100644 --- a/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts +++ b/packages/compiler/src/frontend/lowering/builtins/text-codecs.ts @@ -5,6 +5,7 @@ import { staticTextDecoderEncoding } from "../text-decoder-encoding.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; +import { InternalCompilerError } from "../../../errors.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { BOOL, @@ -331,6 +332,30 @@ function stringDecoderHelper( }, loc, }; + // end(undefined) only flushes, as Node's end skips the write when + // its chunk is undefined. + const [write, next] = body.splice(0, 2, { + kind: "varDecl", + localId: "s.0", + init: { kind: "strLit", value: "", type: STRING, loc }, + loc, + }); + if (write?.kind !== "varDecl" || write.init === null || next === undefined) + throw new InternalCompilerError("StringDecoder.end helper without its write"); + locals.find((local) => local.id === "s.0")!.mutable = true; + body.splice(1, 0, { + kind: "if", + cond: { + kind: "dynTest", + test: "undefined", + value: varRef("chunk.0", DYN, loc), + type: BOOL, + loc, + }, + then: [], + else_: [{ kind: "assign", localId: "s.0", value: write.init, loc }, next], + loc, + }); } } if (op === "endString" || op === "writeString") { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 7ea2ec8c5..5c4a6c056 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -748,6 +748,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.keyDelete", "dyn.keyDeleteComputed", "dyn.reflectDeleteProperty", + "dyn.reflectHasTarget", "dyn.hasKeyComputed", "dyn.hasOwnComputed", "dyn.propertyIsEnumerableComputed", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a950b4202..1f18af11a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -255,6 +255,8 @@ function buildLibFnSignatures() { "dyn.keyDelete": { argTypes: [DYN, STRING, BOOL], result: VOID }, "dyn.keyDeleteComputed": { argTypes: [DYN, DYN, BOOL], result: VOID }, "dyn.reflectDeleteProperty": { argTypes: [DYN, DYN], result: BOOL }, + /** Reflect.has's target check: Node's TypeError for a primitive. */ + "dyn.reflectHasTarget": { argTypes: [DYN], result: VOID }, "dyn.hasKeyComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.hasOwnComputed": { argTypes: [DYN, DYN], result: BOOL }, "dyn.propertyIsEnumerableComputed": { argTypes: [DYN, DYN], result: BOOL }, diff --git a/packages/runtime/src/scr_bytes_strdec_dyn.c b/packages/runtime/src/scr_bytes_strdec_dyn.c index 1c9bd2998..c23d54523 100644 --- a/packages/runtime/src/scr_bytes_strdec_dyn.c +++ b/packages/runtime/src/scr_bytes_strdec_dyn.c @@ -17,11 +17,9 @@ static ScrBytes *scr_strdec_dyn_view(const ScrDyn *chunk, bool *is_string) { *is_string = chunk->kind == SCR_DYN_STR; if (*is_string) return NULL; - if ( - chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES || - scr_buffer_storage_is(chunk)) { + /* An ArrayBuffer or SharedArrayBuffer is not a view: Node throws. */ + if (chunk->kind == SCR_DYN_TYPED_REF || chunk->kind == SCR_DYN_BYTES) return scr_bytes_buffer_source(chunk); - } scr_dyn_arg_type_fail("buf", "an instance of Buffer, TypedArray, or DataView", chunk); return NULL; } diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2bc1d9a31..d59d7030f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1156,6 +1156,8 @@ void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_field_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_key_placeholder(ScrDyn *value, ScrDyn *key); bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key); +/* Reflect.has's target check: Node's TypeError pending for a primitive. */ +void scr_dyn_reflect_has_target(const ScrDyn *target); bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key); bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key); void scr_dyn_key_delete_computed(ScrDyn *value, const ScrDyn *key, bool strict); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index ddc5bf685..dbd5a606d 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1092,7 +1092,6 @@ static const char *const scr_stdio_names[] = { #endif static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; -static SCR_TL bool scr_stdio_raw; static ScrDyn *scr_stdio_refusal(const char *name) { char message[192]; @@ -1524,7 +1523,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc result = scr_stdio_read(arg); } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); - if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } + if (!scr_exc_pending()) result = scr_dyn_retain(self); #ifdef SCR_STDIO_EXTENDED } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { ScrStr *enc = scr_strdec_encoding(arg); @@ -1555,7 +1554,7 @@ static ScrDyn *scr_stdio_get(void *ptr, const char *key, size_t length) { if (stream->fd == 0 && length == 14 && memcmp(key, "readableLength", 14) == 0) return scr_dyn_new_num((double)scr_stdin_buffered); if (stream->fd == 0 && length == 5 && memcmp(key, "isRaw", 5) == 0) - return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_stdio_raw) : scr_dyn_retain(scr_dyn_undefined()); + return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_process_stdin_raw()) : scr_dyn_retain(scr_dyn_undefined()); if (stream->fd == 0 && length == 9 && memcmp(key, "destroyed", 9) == 0) return scr_dyn_new_bool(scr_stdin_destroyed); if (length == 3 && memcmp(key, "off", 3) == 0) return scr_dyn_retain(scr_stdio_methods[STDIO_OFF]); if (length == 11 && memcmp(key, "addListener", 11) == 0) return scr_dyn_retain(scr_stdio_methods[STDIO_ON]); @@ -1827,7 +1826,7 @@ void scr_process_on_exit(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, doub double scr_process_stdin_is_raw(void) { if (!scr_context_is_main()) return -1; if (!scr_process_is_tty(0)) return -1; - return scr_stdio_raw ? 1.0 : 0.0; + return scr_process_stdin_raw() ? 1.0 : 0.0; } /* process.prependListener("exit", cb): the same listener in FRONT of the diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 090bfbfc8..9198bd145 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -9403,6 +9403,25 @@ static bool scr_dyn_key_probe_computed(const ScrDyn *value, const ScrDyn *raw_ke bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 0); } bool scr_dyn_has_own_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 1); } + +/* Reflect.has's target check: Node's TypeError for a primitive target. */ +void scr_dyn_reflect_has_target(const ScrDyn *target) { + switch (target->kind) { + case SCR_DYN_NULL: + case SCR_DYN_UNDEF: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: { + static const char message[] = "Reflect.has called on non-object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return; + } + default: + return; + } +} bool scr_dyn_property_is_enumerable_computed(const ScrDyn *value, const ScrDyn *key) { return scr_dyn_key_probe_computed(value, key, 2); } static void scr_json_delete_member(ScrDyn *object, const ScrStr *key); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index f8792d129..6556605ab 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5347,7 +5347,12 @@ double scr_process_rows(double fd) { return scr_process_dimension(fd, true); } * setRawMode(false) restores the termios saved at the first raw entry * (libuv's orig_termios), a no-op when raw mode was never entered. * NON-TTY stdin: Node's process.stdin is a Socket with no setRawMode - * member at all, so the call throws Node's exact catchable TypeError. */ + * member at all, so the call throws Node's exact catchable TypeError. + * A TTY call records the mode for process.stdin.isRaw. */ +static SCR_TL bool scr_stdin_raw; + +bool scr_process_stdin_raw(void) { return scr_stdin_raw; } + #ifdef _WIN32 static SCR_TL DWORD scr_stdin_cooked; static SCR_TL bool scr_stdin_cooked_saved = false; @@ -5358,6 +5363,7 @@ void scr_process_stdin_set_raw_mode(bool raw) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return; } + scr_stdin_raw = raw; /* libuv's uv_tty_set_mode(UV_TTY_MODE_RAW) console half: drop line * buffering, echo, and Ctrl-C cooking; restore the entry mode on the * way back — the termios save/restore shape, translated. */ @@ -5391,6 +5397,7 @@ void scr_process_stdin_set_raw_mode(bool raw) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return; } + scr_stdin_raw = raw; if (raw) { struct termios t; if (tcgetattr(0, &t) != 0) return; diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index d6b55a21d..9250b1bca 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -401,6 +401,8 @@ 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); +/* The mode the last TTY setRawMode call set (process.stdin.isRaw). */ +bool scr_process_stdin_raw(void); /* Terminal width for process.stdout/stderr.columns (fd 1/2): * ioctl(TIOCGWINSZ), -1 when the fd is not a TTY or the ioctl refuses — diff --git a/tests/corpus/8426-reflect-object-reflections.ts b/tests/corpus/8426-reflect-object-reflections.ts index bae308d81..6dea53e79 100644 --- a/tests/corpus/8426-reflect-object-reflections.ts +++ b/tests/corpus/8426-reflect-object-reflections.ts @@ -26,3 +26,22 @@ console.log(Object.getOwnPropertyNames(list).length); const descriptors = Object.getOwnPropertyDescriptors(record); console.log(Object.keys(descriptors).join(",")); console.log(Object.getOwnPropertySymbols(record).length); +// A primitive target held as any, unknown or a union throws Node's +// TypeError; its key is evaluated first. +function hasThrough(target: unknown, name: string): string { + try { + return String(Reflect.has(target as any, name)); + } catch (e) { + return `${(e as Error).name}: ${(e as Error).message}`; + } +} +const loose: any = "text"; +const either: string | { a: number } = Math.random() > 2 ? { a: 1 } : "text"; +console.log(hasThrough(loose, "length"), hasThrough(42, "x"), hasThrough(undefined, "x")); +console.log(hasThrough({ a: 1 }, "a"), hasThrough(either, "a")); +let keys = 0; +try { + Reflect.has(loose, String(++keys)); +} catch (e) { + console.log((e as Error).name, keys); +} diff --git a/tests/corpus/8428-string-decoder-dynamic-chunks.ts b/tests/corpus/8428-string-decoder-dynamic-chunks.ts index 6f97f1079..7ec060e56 100644 --- a/tests/corpus/8428-string-decoder-dynamic-chunks.ts +++ b/tests/corpus/8428-string-decoder-dynamic-chunks.ts @@ -44,3 +44,28 @@ try { const err = e as Error; console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); } +// end(undefined) only flushes; null is not a chunk. +const flushed = new StringDecoder("utf8"); +console.log(JSON.stringify(write(flushed, Buffer.from([0x61, 0xe2, 0x82])))); +console.log(JSON.stringify(endWith(flushed, undefined))); +try { + endWith(new StringDecoder("utf8"), null); +} catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); +} +// An ArrayBuffer or SharedArrayBuffer is not an ArrayBufferView. +for (const chunk of [new ArrayBuffer(2), new SharedArrayBuffer(2)]) { + try { + write(new StringDecoder("utf8"), chunk); + } catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); + } + try { + endWith(new StringDecoder("utf8"), chunk); + } catch (e) { + const err = e as Error; + console.log(err.name, (err as NodeJS.ErrnoException).code, err.message); + } +} diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 3daa5357e..35295f87b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -7,15 +7,6 @@ stdlib-fence.ts:17:23 - error SC2020: 'Object.assign' is part of the standard li hint: spread instead: { ...a, ...b } builds the merged record; what lowers: the empty-target literal-source shape (Object.assign({}, { ... }) IS the source literal) and merges INTO an index-signature record whose value slot every source value enters — other aliased targets are real mutation, and a function target (Object.assign(fn, { prop })) is a function-with-properties value the model cannot represent: bind the property separately -stdlib-fence.ts:18:14 - error SC2020: 'Reflect.has' is part of the standard library types but has no scriptc lowering yet - - 17 | const aliasedAssign = Object.assign(merged, { b: 2 }); // aliased targets stay fenced (real mutation of a live object) - 18 | const refl = Reflect.has({ a: 1 }, "a"); - | ^~~~~~~~~~~ - 19 | const parsedMs = Date.parse; - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - stdlib-fence.ts:19:18 - error SC2020: 'Date.parse' is part of the standard library types but has no scriptc lowering yet 18 | const refl = Reflect.has({ a: 1 }, "a");