From 2b03f66ff3c4c47d9b42995f03b30652ad5fd701 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:16:00 -0700 Subject: [PATCH 1/7] Host patching H1: typed process output write slot Reading `process.stdout.write` as a value, `write.bind(stream)`, and `process.stdout.write = fn` now reach the runtime stream object's write slot in TypeScript, and a program that replaces an output write method calls its typed writes through that slot (SC8020 refuses the combination with Node islands until H2 shares the stream object with them). Co-Authored-By: Claude Opus 5.5 --- .../console-and-process-streams.mdx | 3 +- .../generated/node-v24-internal.json | 4 + internal/compatibility/static-support.json | 2 +- .../compiler/src/diagnostics/diagnostic.ts | 18 +++ packages/compiler/src/executable/prepare.ts | 9 ++ .../src/frontend/lowering/builtins/process.ts | 112 ++++++++++++++++++ .../src/frontend/lowering/lower-stmts.ts | 35 +++++- .../compiler/src/frontend/lowering/lowerer.ts | 24 ++++ packages/compiler/src/frontend/type-mapper.ts | 36 ++++++ .../tests/corpus/stdout-write-patch.ts.json | 6 + .../native-shim-monkeypatching.ts.json | 6 + packages/runtime/src/scr_events.c | 3 +- tests/corpus/stdout-write-patch.ts | 42 +++++++ .../diagnostics/native-shim-monkeypatching.ts | 20 ++++ .../native-shim-monkeypatching.ts.txt | 26 ++++ .../corpus-costs/stdout-write-patch.ts.json | 1 + 16 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/native-shim-monkeypatching.ts.json create mode 100644 tests/corpus/stdout-write-patch.ts create mode 100644 tests/diagnostics/native-shim-monkeypatching.ts create mode 100644 tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt create mode 100644 tests/harness/corpus-costs/stdout-write-patch.ts.json diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index 946cb8061..a877512c1 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -12,7 +12,8 @@ Stored global console values and detached output methods retain identity and acc Stored methods do not support format specifiers with substitution arguments. Other stored console APIs, custom `Console` instances, and console mutation are unsupported. -JavaScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver. +JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, replace output `write` methods, and call saved methods with their stream receiver. +A replaced `write` method does not receive console's error-callback argument. Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 00d6f8db3..240eeceda 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79689,6 +79689,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdio-write-overrides.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/fixtures/node-types/env-files.ts", "tests/fixtures/node-types/util-callables.ts", "tests/harness/event-loop.test.ts", @@ -82047,6 +82048,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82089,6 +82091,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82131,6 +82134,7 @@ "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", + "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1242ad9a7..72ed41fdb 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -314,7 +314,7 @@ { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, { "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] }, - { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts"] }, { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0cabb6ddb..3322b3af8 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -366,6 +366,24 @@ export function sharedModuleDuplicatedDiag( }; } +/** SC8020 — the program replaces a process output stream's write method + * while a Node island exists: the island's `process.stdout` is its own + * object, so the replacement and the island's writes cannot see each other. + * H2 shares the native stream object with the islands and removes this. */ +export function stdioPatchAcrossIslandsDiag( + stream: "stdout" | "stderr" | null, + loc: SrcLoc, +): ScrDiagnostic { + const what = + stream === null ? "process.stdout.write or process.stderr.write" : `process.${stream}.write`; + return { + code: "SC8020", + message: `${what} is replaced while a Node island exists: the island's process output stream would not see this patch, and island writes would bypass it`, + loc, + hint: "the island's process.stdout would not see this patch", + }; +} + /** SC8022 — island code extends a class of a module the program compiles. * A native instance cannot take an island subclass's overrides, so the * module is not owned by the program: a package runs in Node for both diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index f2b955f85..19081e35e 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -7,6 +7,7 @@ import { npmStaticRefusalDiag, iceDiag, isCheckerPanic, + stdioPatchAcrossIslandsDiag, type ScrDiagnostic, } from "../diagnostics/diagnostic.js"; import type { IrModule, SrcLoc } from "../ir/ir.js"; @@ -168,6 +169,14 @@ export function prepareExecutableModule( return fail(lowered.diagnostics); } + // A program that replaces a process output stream's write method has + // nowhere to route an island's writes: the island's process.stdout is + // its own object. Until islands share the native stream object (H2), + // refuse the combination instead of silently missing the patch. + if (islandDecision !== null && lowered.stdioPatches !== undefined) { + return fail(lowered.stdioPatches.map((p) => stdioPatchAcrossIslandsDiag(p.stream, p.loc))); + } + const validation = validateModule(lowered.module); timing("ir-validate"); if (validation.length > 0) { diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2eac7a17d..065183a69 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -34,6 +34,7 @@ import { builtinFunctionValue } from "../lower-exprs.js"; import { canonicalBuiltinModule } from "../../builtin-modules.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; +import { isCheckerPanic } from "../../../diagnostics/diagnostic.js"; /** Whether the process has an IPC channel, Node's test for defining * process.send and process.disconnect: a channel this fork child opened @@ -173,6 +174,70 @@ export function programReadsExitCode(lowerer: Lowerer): boolean { return found; } +/** A write-method replacement on a process output stream, its source site. + * `stream` is null when the receiver is a first-class alias, whose type does + * not say which of the two output streams it holds. */ +export interface StdioPatchSite { + stream: "stdout" | "stderr" | null; + loc: SrcLoc; +} + +/** Whether any program file replaces a process output stream's write + * method: `process.stdout.write = …` / `process.stderr.write = …`, or the + * same assignment through a first-class alias of the stream (a value whose + * type maps to the process stream). Such a program routes its typed + * stdout/stderr writes through the stream object, so the replacement sees + * them with their arguments, as in Node. Cached per program; the first + * site found is recorded for the executable planner's SC8020 check. */ +export function programPatchesStdio(lowerer: Lowerer): boolean { + if (lowerer.patchesStdio !== null) return lowerer.patchesStdio; + let found = false; + // undefined: not an output stream; null: a first-class alias of one. + const streamOf = (recv: ts.Expression): "stdout" | "stderr" | null | undefined => { + if (ts.isPropertyAccessExpression(recv)) { + const direct = lowerer.stdlibGlobalMember(recv, "process"); + if (direct === "stdout" || direct === "stderr") return direct; + } + // An arbitrary receiver's mapping is a query, never a commit: a checker + // panic (the type has no readable form) is not a patch site. The type + // cannot say WHICH output stream an alias holds. + try { + return lowerer.mapTypeOf(lowerer.typeOf(recv))?.kind === "procStream" ? null : undefined; + } catch (e) { + if (!isCheckerPanic(e)) throw e; + return undefined; + } + }; + const walk = (node: ts.Node): void => { + if (found) return; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.name.text === "write" + ) { + const recv = node.left.expression; + const direct = ts.isPropertyAccessExpression(recv) + ? lowerer.stdlibGlobalMember(recv, "process") + : null; + const stream = direct === "stdout" || direct === "stderr" ? direct : streamOf(recv); + if (stream !== undefined) { + found = true; + lowerer.stdioPatchSites.push({ stream, loc: locOf(node) }); + return; + } + } + ts.forEachChild(node, walk); + }; + for (const sf of lowerer.program.getImplementationSourceFiles()) { + if (sf.isDeclarationFile) continue; + walk(sf); + if (found) break; + } + lowerer.patchesStdio = found; + return found; +} + /** The pinned Node release the runtime reports as process.versions.node * (packages/runtime/src/scr_lib.c SCR_NODE_COMPAT_VERSION, kept equal to * internal/compatibility/node-v24.json by test/node-pin.test.ts). */ @@ -341,6 +406,26 @@ export function lowerProcessStreamProperty( loc: locOf(expr), }; } + // The write method as a VALUE in TypeScript (`const original = + // process.stdout.write`, `process.stdout.write.bind(process.stdout)`): + // the stream object's current write slot, a checked-dynamic function + // (the native method until a program replaces it). + if (member === "write" && (direct === "stdout" || direct === "stderr")) { + const loc = locOf(expr); + return { + kind: "dynKeyGet", + value: { + kind: "libCall", + fn: "process.stdio", + args: [numLit(direct === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + key: strLit("write", locOf(expr.name)), + type: DYN, + loc, + }; + } // `if (process.stdin.setRawMode)`: Node's stdin has the method only // when it is a TTY. The runtime's stream object answers the same // presence (the method or undefined); calls keep their direct lowering. @@ -1073,6 +1158,33 @@ export function lowerProcessMethodCall( if (stream === "stdout" || stream === "stderr") { const loc = locOf(call); const args = call.arguments.nodes; + // In a program that replaces a process stream's write, a write call is + // what Node makes it: a call of the stream's CURRENT write property, + // with the arguments as passed (the replacement sees byte chunks, + // encodings and callbacks). The native method behind the slot is the + // same synchronous write, so an unpatched stream behaves as before. + if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) { + const handle: IrExpr = { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }; + const invoke: IrExpr = { + kind: "dynInvoke", + recv: handle, + method: "write", + calleeName: ts.getTextOfNode(access), + args: args.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; + // The declared boolean: a replacement returning something else + // throws at a use that needs the boolean (types do not lie); a + // discarded result is not converted. + return resultIsDiscarded(call) ? invoke : lowerer.coerceToExpected(invoke, BOOL); + } if (args.length < 1 || args.length > 3 || args.some(ts.isSpreadElement)) { lowerer.noLowering( `process.${stream}.write with ${args.length} arguments`, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..cfea92522 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -25,7 +25,7 @@ import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, } from "./containers/match-row-uses.js"; -import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { runtimeKeyPatternRead } from "./expressions/keyed-field-targets.js"; import { declaresHoistedBinding, staticCondition } from "./static-typeof.js"; import { lowerCollectionSpread } from "./containers/collection-methods.js"; @@ -9196,6 +9196,39 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm }; } } + // process.stdout.write = fn (and stderr): replace the stream object's + // write slot. Every later write to the stream — console output, typed + // write calls (programPatchesStdio), runtime writers — goes through it. + if (ts.isPropertyAccessExpression(expr.left) && expr.left.name.text === "write") { + const recv = expr.left.expression; + const stream = ts.isPropertyAccessExpression(recv) + ? lowerer.stdlibGlobalMember(recv, "process") + : null; + if (stream === "stdout" || stream === "stderr") { + const loc = locOf(expr); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ + { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + strLit("write", locOf(expr.left.name)), + lowerer.lowerExprExpecting(expr.right, DYN), + ], + type: VOID, + loc, + }, + loc, + }; + } + } if (!ts.isIdentifier(expr.left)) { lowerer.unsupported("SC1090", expr.left, "assignment to non-variables"); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 027e8a8f6..37b35ceaa 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -100,6 +100,8 @@ import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions. import { isOptionalProcessStreamProperty, isOptionalProcessVersionsRead, + programPatchesStdio, + type StdioPatchSite, } from "./builtins/process.js"; import { isSettledIteratorValueRead } from "./lower-generators.js"; import { isRebindableFunctionSymbol } from "./rebindable-functions.js"; @@ -952,6 +954,11 @@ export interface LowerResult { dynamicFunctions?: DynamicFunction[]; /** LowerOptions.tierReport: the report-only tier assignment. */ tiers?: TierReport; + /** The program replaces a process output stream's write method, with the + * site: the executable planner refuses the combination with Node islands + * (SC8020), whose process objects would not see the patch. Present only + * when the build has islands. */ + stdioPatches?: StdioPatchSite[]; } export interface LowerOptions { @@ -2605,6 +2612,12 @@ export class Lowerer { streamPropMutatedSyms: Set | null = null; /** Whether some source reads process.exitCode (processReadsExitCode). */ readsExitCode: boolean | null = null; + /** Whether some source replaces a process output stream's write method + * (programPatchesStdio in builtins/process.ts). */ + patchesStdio: boolean | null = null; + /** The write-replacement site programPatchesStdio found: the executable + * planner refuses it beside Node islands (SC8020). */ + readonly stdioPatchSites: StdioPatchSite[] = []; requestInitPropMutatedSyms: Set | null = null; emitterEventTable: Map | null = null; emitterComputedPatterns: ComputedEventPattern[] = []; @@ -4395,6 +4408,8 @@ export class Lowerer { "unixListenReachable", "streamPropMutatedSyms", "readsExitCode", + "patchesStdio", + "stdioPatchSites", "requestInitPropMutatedSyms", "classProtocols", "cjsSplitRequireBindings", @@ -9720,11 +9735,20 @@ export class Lowerer { // rewrite keeps every dynamic operation dynamic (each dyn operator // becomes its js twin), so coverage's dynamic statements are unchanged. if (module && this.dynamicFunctions.length > 0) promoteJsvals(module); + // A program that replaces a process output stream's write method beside + // Node islands is refused by the executable planner (SC8020): an + // island's process.stdout is its own object. Only island builds pay for + // the whole-program scan. + const stdioPatches = + this.nodeIslands !== null && programPatchesStdio(this) + ? [...this.stdioPatchSites] + : undefined; return { module, diagnostics: this.diags, runtimeFences: this.runtimeFences, stats: this.stats, + ...(stdioPatches !== undefined && stdioPatches.length > 0 ? { stdioPatches } : {}), ...(this.dynamicFunctions.length > 0 ? { dynamicFunctions: this.dynamicFunctions } : {}), ...(this.statsByFile.size > 0 ? { statsByFile: this.statsByFile } : {}), ...(this.provenanceElided.length > 0 ? { provenanceElided: this.provenanceElided } : {}), diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 36dae9b10..47d8d891d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5101,6 +5101,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // AsyncLocalStorage.snapshot()'s generic runner is a dyn function value // (als.snapshot), called through the dyn call path. if (callSigs.length === 1 && isAsyncLocalStorageSnapshot(callSigs[0]!, ctx)) return DYN; + // A process/net/stream `write` method taken as a value (`typeof + // process.stdout.write`, the saved original of an output guard): its + // overload set has no single compiled signature, and the value is the + // stream's replaceable method slot, so it is the checked-dynamic function + // the stream object hands out (scr_events.c ProcessStream). + if (callSigs.length > 1 && isStdlibStreamWriteOverloads(callSigs, ctx)) return DYN; if (callSigs.length === 1) return mapCallSignature(callSigs[0]!, ctx); // Records: object types whose members are all data properties (shorthand // methods in type position count — they're func-typed fields) with @@ -5503,6 +5509,36 @@ function isAsyncLocalStorageSnapshot(sig: ts.Signature, ctx: TypeMapperCtx): boo ); } +const STREAM_WRITE_OWNERS = new Set(["Socket", "Writable", "WriteStream", "WritableStream"]); + +/** Every signature is a `write` method declared on a standard-library + * stream interface or class (net.Socket, stream.Writable, tty.WriteStream, + * NodeJS.WritableStream) — @types/node's or the fallback declarations'. */ +function isStdlibStreamWriteOverloads( + sigs: readonly ts.Signature[], + ctx: Pick, +): boolean { + return sigs.every((sig) => { + const decl = sig.declaration; + if ( + decl === undefined || + !(ts.isMethodSignatureDeclaration(decl) || ts.isMethodDeclaration(decl)) + ) + return false; + if (!ts.isIdentifier(decl.name) || decl.name.text !== "write") return false; + const owner = decl.parent; + const ownerName = + owner !== undefined && (ts.isInterfaceDeclaration(owner) || ts.isClassDeclaration(owner)) + ? (owner.name?.text ?? null) + : null; + return ( + ownerName !== null && + STREAM_WRITE_OWNERS.has(ownerName) && + ctx.isStdlibFile(ts.getSourceFileOfNode(decl)) + ); + }); +} + function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null { const { checker } = ctx; if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json new file mode 100644 index 000000000..bacb95fe0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stdout-write-patch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stdout-write-patch.ts" + ], + "diags": [] +} 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/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 78b7bb0f1..9de2c6ce2 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1580,7 +1580,8 @@ ScrDyn *scr_process_stdio(double fd) { ScrClosure *cb = scr_closure_new(NULL, 1); cb->caps[0] = scr_box_new(SCR_BOX_F64); scr_box_set_f64(cb->caps[0], i); - scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]); + scr_stdio_methods[i] = scr_dyn_new_func( + cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", i == STDIO_WRITE ? "" : scr_stdio_names[i]); } scr_stdio_write_hook = scr_stdio_intercept; } diff --git a/tests/corpus/stdout-write-patch.ts b/tests/corpus/stdout-write-patch.ts new file mode 100644 index 000000000..c5e960be0 --- /dev/null +++ b/tests/corpus/stdout-write-patch.ts @@ -0,0 +1,42 @@ +// Replacing process.stdout.write from TypeScript: the output-guard idiom. +// The real write methods are taken first with bind (they write straight +// to the descriptor, past any later patch). The replacement then sees +// every stdout write: console.log's lines, direct string and byte writes +// with their encoding and callback arguments as passed. Assigning the +// saved method back restores the stream. +type RawWrite = (chunk: string, callback?: (error?: Error | null) => void) => boolean; + +const rawStdout = process.stdout.write.bind(process.stdout) as RawWrite; +const rawStderr = process.stderr.write.bind(process.stderr) as RawWrite; +const original = process.stdout.write; +const captured: string[] = []; + +function text(chunk: string | Uint8Array): string { + return typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); +} + +process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, +): boolean => { + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : "-"; + captured.push(`${typeof chunk === "string" ? "string" : "bytes"}/${encoding}:${text(chunk).trimEnd()}`); + return rawStderr(`[redirected] ${text(chunk)}`, done); +}) as typeof process.stdout.write; + +console.log("from console.log", 42); +process.stdout.write("direct string\n"); +process.stdout.write(new Uint8Array([104, 105, 10])); +process.stdout.write("6869210a", "hex"); +process.stdout.write("with callback\n", (error) => { + rawStderr(`callback error is null: ${error === null}\n`); +}); +const accepted = process.stdout.write("result\n"); +rawStdout("raw stdout bypasses the patch\n"); +console.log("same method", process.stdout.write === original, accepted); + +process.stdout.write = original; +console.log("restored", process.stdout.write === original); +console.log(captured.join(" | ")); diff --git a/tests/diagnostics/native-shim-monkeypatching.ts b/tests/diagnostics/native-shim-monkeypatching.ts new file mode 100644 index 000000000..5a6613be1 --- /dev/null +++ b/tests/diagnostics/native-shim-monkeypatching.ts @@ -0,0 +1,20 @@ +// Patching a NATIVE host object: Node lets a library swap +// `process.stdout.write`, hand out `process.stdout.write.bind(...)`, or +// redefine a module member with Object.defineProperty. The process output +// stream's write slot is the runtime stream object's, so the assignment and +// the bind compile. A builtin module's exports are direct calls, so those +// forms keep an explicit refusal instead of silently patching nothing. The +// working split: use the module's functions directly. + +import * as fs from "node:fs"; + +export function bindReadFileSync(): typeof fs.readFileSync { + return fs.readFileSync.bind(fs); +} + +export function defineReadFileSync(): void { + Object.defineProperty(fs, "readFileSync", { value: () => "" }); +} + +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..d25f2e984 --- /dev/null +++ b/tests/harness/__snapshots__/native-shim-monkeypatching.ts.txt @@ -0,0 +1,26 @@ +native-shim-monkeypatching.ts:11: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 + + 10 | + 11 | export function bindReadFileSync(): typeof fs.readFileSync { + | ^~~~~~~~~~~~~~~~ + 12 | 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:16:3 - error SC2020: 'Object.defineProperty' is part of the standard library types but has no scriptc lowering yet + + 15 | export function defineReadFileSync(): void { + 16 | Object.defineProperty(fs, "readFileSync", { value: () => "" }); + | ^~~~~~~~~~~~~~~~~~~~~ + 17 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +native-shim-monkeypatching.ts:19:1 - error SC2004: uses of 'bindReadFileSync' inherit the blocker on its declaration + + 18 | + 19 | bindReadFileSync(); + | ^~~~~~~~~~~~~~~~ + 20 | 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 diff --git a/tests/harness/corpus-costs/stdout-write-patch.ts.json b/tests/harness/corpus-costs/stdout-write-patch.ts.json new file mode 100644 index 000000000..7a430121c --- /dev/null +++ b/tests/harness/corpus-costs/stdout-write-patch.ts.json @@ -0,0 +1 @@ +{ "plain": 3200, "san": 2700 } From cf369f70af1f0e0d28046c4c49333c7633755fef Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:28 -0700 Subject: [PATCH 2/7] Fix write callbacks that take the error argument (W3) `writable.write(chunk, cb)` with `cb` passed as a function VALUE lost the completion's error: `lowerWriteCb` checked only the callback's syntax and lowered every other callback through `lowerExprExpecting(cbArg, funcOf([], VOID))`, which adapts a one-parameter function down to zero parameters. The native binary therefore printed `undefined` where Node prints `null`, or the write's own error (`ERR_STREAM_WRITE_AFTER_END` / `ERR_STREAM_DESTROYED` for late and destroyed writes). Lower such callbacks through a new `writable.writeEcb` libCall `(recv, mode, chunk, encoding, cb)`: a zero-parameter callback keeps the existing synchronous path, and a callback that declares the error (a dyn value, or `null | Error`) is wrapped by `scr_stream_ecb_wrap` with the compiler-built adapter that delivers the error the write completion carries. The stream unit publishes that error only in its `SCR_STREAM_WRITE_ERRORS` build, selected by the new `streamWriteErrors` runtime feature (rule: any `writable.writeEcb`). Every other build compiles `SCR_WCB_CALL` to the same plain call, so their objects are unchanged. The destruction path also matched Node: `afterWrite` calls `cb(null)` even for a stream destroyed after the write was queued (Node 24 `internal/streams/writable`), so the destroyed `SCR_ST_AFTER_WRITE` tick passes NULL rather than the stream's stored error. Corpus: `tests/corpus/writable-error-callbacks.ts` covers success, an erroring write, a write after end, and a write on a destroyed stream. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 93 ++++++++++++------- internal/compatibility/static-support.json | 2 +- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../compiler/src/backend/llvm/lib-stream.ts | 38 ++++++++ .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 3 + .../compiler/src/backend/native/executable.ts | 3 + .../compiler/src/backend/runtime-pack-core.ts | 4 + .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/lower-stream.ts | 58 ++++++++++-- packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/src/ir/runtime-features.ts | 5 + .../compiler/src/native/sanitizer.test.ts | 1 + .../corpus/writable-error-callbacks.ts.json | 6 ++ .../runtime-darwin-arm64/scripts/build.mjs | 1 + .../runtime-pack-matrix.mjs | 10 ++ .../runtime-pack-common/scripts/build.mjs | 1 + packages/runtime/src/scr_stream.c | 77 +++++++++++++-- packages/runtime/src/scr_stream.h | 8 ++ tests/corpus/writable-error-callbacks.ts | 53 +++++++++++ .../writable-error-callbacks.ts.json | 1 + 24 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json create mode 100644 tests/corpus/writable-error-callbacks.ts create mode 100644 tests/harness/corpus-costs/writable-error-callbacks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 240eeceda..928bea3bb 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -86937,7 +86937,8 @@ "tests/corpus/5611-readable-from-class-iterables.ts", "tests/corpus/5612-readable-from-iterables.cjs", "tests/corpus/5617-pipeline-function-ends.ts", - "tests/corpus/5619-pipeline-function-ends.cjs" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "chapter" @@ -87063,7 +87064,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87097,7 +87099,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87131,7 +87134,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87188,7 +87192,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87222,7 +87227,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87256,7 +87262,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87652,7 +87659,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87709,7 +87717,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87743,7 +87752,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -87800,7 +87810,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -87834,7 +87845,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -87868,7 +87880,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -88264,7 +88277,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -88367,7 +88381,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88427,7 +88442,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88461,7 +88477,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88495,7 +88512,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -88552,7 +88570,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89145,7 +89164,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89179,7 +89199,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89213,7 +89234,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89247,7 +89269,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89281,7 +89304,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89315,7 +89339,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89349,7 +89374,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89383,7 +89409,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -89417,7 +89444,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -90836,7 +90864,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "exact" @@ -91100,7 +91129,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" @@ -91134,7 +91164,8 @@ "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" + "tests/corpus/5619-pipeline-function-ends.cjs", + "tests/corpus/writable-error-callbacks.ts" ] }, "anchorSource": "ancestor" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 72ed41fdb..96e4418db 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -478,7 +478,7 @@ { "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": ["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"] }, + { "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", "tests/corpus/writable-error-callbacks.ts"] }, { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"] }, { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f4290a15a..f333aff7e 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..9ed4e3a18 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1950,6 +1950,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "writable.writeU", "writable.writeDyn", "writable.writeEnc", + "writable.writeEcb", "writable.end", "writable.uncork", "stream.destroy", diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts index 53f455964..89a97d002 100644 --- a/packages/compiler/src/backend/llvm/lib-stream.ts +++ b/packages/compiler/src/backend/llvm/lib-stream.ts @@ -455,6 +455,44 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV host.emitPendingCheck(); return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type }; } + if (e.fn === "writable.writeEcb") { + // (recv, mode, chunk, encoding, cb): mode 0 bytes, 1 string, 2 dyn. The + // callback MOVES into an error-first wrapper (scr_stream_ecb_wrap); the + // adapter turns the runtime's ScrError (NULL = success) into the + // callback's Error | null argument, as fs.rename callbacks do. + const modeArg = e.args[1]!; + if (modeArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: writable.writeEcb mode not a literal"); + const cbT = e.args[4]!.type; + if (cbT.kind !== "func") + throw new InternalCompilerError("llvm emitter bug: writable.writeEcb callback not a func"); + const args = e.args.map((a) => host.emitExpr(a)); + host.toHeap(args[4]!); + const adapter = host.fsRenameThunkFor(cbT); + host.declare(`declare ptr @scr_stream_ecb_wrap(ptr, ptr)`); + const wrapped = B.tmp(); + B.line(`${wrapped} = call ptr @scr_stream_ecb_wrap(ptr ${args[4]!.name}, ptr @${adapter})`); + const entry = + modeArg.value === 0 + ? "scr_stream_write" + : modeArg.value === 1 + ? "scr_stream_write_str" + : null; + const t = B.tmp(); + if (entry !== null) { + host.declare(`declare zeroext i1 @${entry}(ptr, ptr, ptr)`); + B.line( + `${t} = call zeroext i1 @${entry}(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${wrapped})`, + ); + } else { + host.declare(`declare zeroext i1 @scr_stream_write_enc(ptr, ptr, ptr, ptr)`); + B.line( + `${t} = call zeroext i1 @scr_stream_write_enc(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${args[3]!.name}, ptr ${wrapped})`, + ); + } + host.emitPendingCheck(); + return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type }; + } if (e.fn === "writable.end") { // (recv, flags[, chunk][, encoding][, cb]) — flags: 1 bytes chunk, 2 // string chunk, 8 dyn chunk (write first, Node's end(chunk) diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 6a40a1be7..54aa87b4d 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,9 @@ 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; + /** A write completion callback takes the error argument: links the + * SCR_STREAM_WRITE_ERRORS build of the stream unit. */ + streamWriteErrors?: 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..a58d2310f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -161,6 +161,9 @@ export interface CcOptions { warningReplace?: boolean; /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ stdioExtended?: boolean; + /** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each + * write completion callback receives (error-taking write callbacks). */ + streamWriteErrors?: 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 3515bc387..4d099a6d9 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -409,6 +409,9 @@ 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 }, + // scr_stream.c's write-callback error publication, which the compiler's + // error-first write adapter reads. + { define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors }, // 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/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 2ce4f21fe..cd22be057 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -101,6 +101,9 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { computedRequire: boolean; warningReplace: boolean; stdioExtended: boolean; + /** The program's write completion callbacks take the error argument: + * the SCR_STREAM_WRITE_ERRORS build of the stream unit. */ + streamWriteErrors: boolean; nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; @@ -312,6 +315,7 @@ export function effectiveRuntimeFeatures( computedRequire: features.computedRequire === true, warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, + streamWriteErrors: features.streamWriteErrors === true, nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 25a77c10d..36965eb89 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -65,6 +65,8 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; stdioExtended?: boolean; + /** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */ + streamWriteErrors?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index d8c370511..6d203ae7f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6964,18 +6964,39 @@ export function lowerStreamMethodCall( const kind = chunkKind(args[0]!); if (kind === "null") lowerer.noLowering("write(null)", call, "end() finishes a writable"); const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { - const cb = callbackDeclaresParameters(cbArg) - ? null - : lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); - if (cb === null || cb.type.kind !== "func" || cb.type.params.length > 0) { + const cb = lowerer.lowerExpr(cbArg); + if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( - "write completion callbacks taking the error argument", + "write completion callbacks of this shape", cbArg, - "() => void callbacks are supported (listen for 'error' to observe failures)", + "callbacks take () or (error?: Error | null) and return nothing", ); } - return cb!; + if (cb.type.params.length === 0) return cb; + const param = cb.type.params[0]!; + const def = param.kind === "union" ? lowerer.unions.get(param.unionId) : undefined; + const ok = + param.kind === "dyn" || + (!!def && + def.arms.some((a) => a.kind === "nullT") && + def.arms.some((a) => a.kind === "object" && a.className === "%Error") && + def.arms.every( + (a) => + a.kind === "nullT" || + a.kind === "undefinedT" || + (a.kind === "object" && a.className === "%Error"), + )); + if (!ok) { + lowerer.unsupported( + "SC1090", + cbArg, + `write completion callbacks whose parameter is not 'Error | null' (got '${lowerer.fmt(param)}')`, + ); + } + return cb; }; + const takesError = (cb: IrExpr): boolean => + cb.type.kind === "func" && cb.type.params.length === 1; // write(chunk, encoding[, cb]), and write(value, cb): the encoding // argument reaches an object-mode side's _write as passed, null when // the callback took its place (a byte-mode side encodes a string @@ -6988,7 +7009,19 @@ export function lowerStreamMethodCall( const chunk = lowerer.lowerExprExpecting(args[0]!, DYN); const tail: IrExpr[] = []; if (encArg !== undefined) tail.push(lowerer.lowerExprExpecting(encArg, DYN)); - if (cbArg !== undefined) tail.push(lowerWriteCb(cbArg)); + if (cbArg !== undefined) { + const cb = lowerWriteCb(cbArg); + if (takesError(cb)) { + return { + kind: "libCall", + fn: "writable.writeEcb", + args: [receiver, numLit(2, loc), chunk, tail[0] ?? dynUndefinedExpr(loc), cb], + type: BOOL, + loc, + }; + } + tail.push(cb); + } const flags = (encArg !== undefined ? 1 : 0) | (cbArg !== undefined ? 2 : 0); return { kind: "libCall", @@ -7055,6 +7088,15 @@ export function lowerStreamMethodCall( const fn = kind === "bytes" ? "writable.write" : "writable.writeStr"; if (cbArg !== undefined) { const cb = lowerWriteCb(cbArg); + if (takesError(cb)) { + return { + kind: "libCall", + fn: "writable.writeEcb", + args: [receiver, numLit(kind === "bytes" ? 0 : 1, loc), chunk, dynUndefinedExpr(loc), cb], + type: BOOL, + loc, + }; + } return { kind: "libCall", fn, args: [receiver, chunk, cb], type: BOOL, loc }; } return { kind: "libCall", fn, args: [receiver, chunk], type: BOOL, loc }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0719112cc..ecd6734a8 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -700,6 +700,7 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), + ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..114231730 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -438,6 +438,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "writable.writeU", "writable.writeDyn", "writable.writeEnc", + "writable.writeEcb", "writable.end", "writable.uncork", "writable.setDefaultEncoding", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..55596d087 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4576,6 +4576,10 @@ function buildLibFnSignatures() { "writable.writeU": { argTypes: [null, null], result: BOOL }, "writable.writeDyn": { argTypes: [null, DYN], result: BOOL }, "writable.writeEnc": { argTypes: [null, F64, DYN], result: BOOL }, + /** The error-first write form: (recv, mode, chunk, encoding, cb) where + * mode 0 is a bytes chunk, 1 a string, 2 a run-time chunk, and the + * callback takes the completion's error (Node's null on success). */ + "writable.writeEcb": { argTypes: [null, F64, null, DYN, null], result: BOOL }, "writable.end": { argTypes: [null, F64], result: VOID }, "writable.cork": { argTypes: [null], result: VOID }, "writable.uncork": { argTypes: [null], result: VOID }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index da4f11623..93b78db14 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1185,6 +1185,10 @@ const RUNTIME_FEATURE_RULES = { (node.kind === "dynInvoke" && STDIO_EXTENDED_NAMES.has(node.method)) || (node.kind === "strLit" && STDIO_EXTENDED_NAMES.has(node.value)), }), + /** A write completion callback that takes the error argument lowers + * through writable.writeEcb: the SCR_STREAM_WRITE_ERRORS stream unit + * publishes the error the adapter delivers. */ + streamWriteErrors: rule({ fns: ["writable.writeEcb"] }), stream: rule({ initial: (mod) => (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), fns: [...STREAM_LIB_FNS, "dyn.builtinStreamClass"], @@ -1309,6 +1313,7 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + streamWriteErrors: false, moduleNamespace: false, moduleNamespaceDyn: false, stream: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index b1d03b52d..0e7bccafd 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -32,6 +32,7 @@ const features = { computedRequire: false, warningReplace: false, stdioExtended: false, + streamWriteErrors: false, nodeIsland: false, brotli: false, moduleNamespace: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json new file mode 100644 index 000000000..bc5a7f0ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/writable-error-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/writable-error-callbacks.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index ecb2a901b..24bde2db5 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -376,6 +376,7 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", + "SCR_STREAM_WRITE_ERRORS", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..5e3b722d2 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -334,6 +334,16 @@ export function variantsFor(source) { defines: ["SCR_NODE_ISLANDS"], }); } + // Error-taking write completion callbacks: the stream unit publishes the + // error each callback receives; every other program keeps the default + // object, byte for byte. + if (source === "scr_stream.c") { + variants.push({ + id: "write-errors", + when: { streamWriteErrors: true }, + defines: ["SCR_STREAM_WRITE_ERRORS"], + }); + } if (source === "scr_cycle.c") { // The young-in nursery (SCRIPTC_YOUNG_NURSERY=1): the collector with // nursery collections; programs built without it link the default. diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 97e03b189..e8af727ea 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -545,6 +545,7 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", + "SCR_STREAM_WRITE_ERRORS", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 872586976..dfdccc238 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -2171,6 +2171,59 @@ static void scr_stream_do_write_inner(ScrStream *s, ScrBytes *chunk /*moves*/, S st->w.wsync = false; } +#ifdef SCR_STREAM_WRITE_ERRORS +/* The error the next user write callback receives (borrowed; NULL is + * success, Node's null). Set immediately around each user write-callback + * call below; read by the error-first wrapper scr_stream_ecb_fire. */ +static SCR_TL ScrError *scr_stream_cb_error = NULL; +#define SCR_WCB_CALL(cb, err) \ + do { \ + ScrError *scr_wcb_saved = scr_stream_cb_error; \ + scr_stream_cb_error = (err); \ + ((void (*)(ScrClosure *))(cb)->fn)(cb); \ + scr_stream_cb_error = scr_wcb_saved; \ + } while (0) +/* The errorBuffer/destroyed-afterWrite form: the stream's own error, or + * the fresh ERR_STREAM_DESTROYED Node passes (owned around the call). */ +#define SCR_WCB_CALL_ERROF(cb, st) \ + do { \ + ScrError *scr_wcb_err = (st)->errored \ + ? scr_error_retain((st)->errored) \ + : scr_stream_mkerr("ERR_STREAM_DESTROYED", \ + "Cannot call write after a stream was destroyed"); \ + SCR_WCB_CALL(cb, scr_wcb_err); \ + scr_error_release(scr_wcb_err); \ + } while (0) + +/* An error-first user callback behind the zero-argument write-callback ABI: + * caps[0] holds the user's closure, caps[1] the compiler-built adapter + * (ScrFsRenameFn: (cb, err-or-NULL) — NULL is Node's null). */ +static void *scr_stream_ecb_retain(void *p) { return p; } +static void scr_stream_ecb_release(void *p) { (void)p; } +static void scr_stream_ecb_trace(void *p, ScrTraceVisit visit, void *ctx) { + (void)p; + (void)visit; + (void)ctx; +} +static void scr_stream_ecb_fire(ScrClosure *wrapper) { + ScrClosure *user = (ScrClosure *)scr_box_get_ref(wrapper->caps[0]); + ScrFsRenameFn fn = (ScrFsRenameFn)scr_box_get_ref(wrapper->caps[1]); + fn(user, scr_stream_cb_error); + scr_closure_release(user); /* scr_box_get_ref returned +1 */ +} +ScrClosure *scr_stream_ecb_wrap(ScrClosure *cb /*moves*/, ScrFsRenameFn fn) { + ScrClosure *w = scr_closure_new((void *)scr_stream_ecb_fire, 2); + w->caps[0] = scr_box_new_obj(scr_closure_retain_v, scr_closure_release_v, scr_closure_trace_v); + scr_box_set_ref(w->caps[0], cb); + w->caps[1] = scr_box_new_obj(scr_stream_ecb_retain, scr_stream_ecb_release, scr_stream_ecb_trace); + scr_box_set_ref(w->caps[1], (void *)fn); + return w; +} +#else +#define SCR_WCB_CALL(cb, err) ((void (*)(ScrClosure *))(cb)->fn)(cb) +#define SCR_WCB_CALL_ERROF(cb, st) SCR_WCB_CALL(cb, NULL) +#endif + /* errorBuffer: once destroyed, the buffered writes' callbacks and the * end(cb) callbacks run (Node passes them ERR_STREAM_DESTROYED or the * stream's error) — unless a write is in flight, whose completion runs @@ -2184,7 +2237,7 @@ static void scr_stream_error_buffer(ScrStream *s) { scr_stream_wchunk_release(st, e.chunk); if (e.enc) scr_dyn_release(e.enc); if (e.cb) { - ((void (*)(ScrClosure *))e.cb->fn)(e.cb); + SCR_WCB_CALL_ERROF(e.cb, st); scr_closure_release(e.cb); } } @@ -2227,7 +2280,7 @@ static void scr_stream_after_write(ScrStream *s, ScrClosure *cb, size_t count) { if (cb) { /* afterWrite: the callback once per write the tick completes */ for (size_t i = 0; i < count; i++) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, NULL); if (scr_exc_pending()) break; } scr_closure_release(cb); @@ -7129,7 +7182,13 @@ static bool scr_stream_write_chunk_enc(ScrStream *s, ScrBytes *chunk /*borrowed* ScrError *e = st->has_w && !st->destroyed ? scr_stream_mkerr("ERR_STREAM_WRITE_AFTER_END", "write after end") : scr_stream_mkerr("ERR_STREAM_DESTROYED", "Cannot call write after a stream was destroyed"); - if (cb) scr_st_tick(s, SCR_ST_WCB_ERR, NULL, cb); + if (cb) { +#ifdef SCR_STREAM_WRITE_ERRORS + scr_st_tick(s, SCR_ST_WCB_ERR, scr_error_retain(e), cb); +#else + scr_st_tick(s, SCR_ST_WCB_ERR, NULL, cb); +#endif + } scr_stream_error_or_destroy(s, e); scr_error_release(e); return false; @@ -7381,7 +7440,7 @@ void scr_stream_write_done(ScrStream *s, ScrError *err /*moves*/) { return; } if (ucb) { - ((void (*)(ScrClosure *))ucb->fn)(ucb); + SCR_WCB_CALL(ucb, err); scr_closure_release(ucb); } if (!scr_exc_pending()) scr_stream_error_or_destroy(s, err); @@ -7970,8 +8029,12 @@ static void scr_stream_run_tick(ScrStreamTick *t) { scr_stream_after_write(s, cb, t->count); } else { if (cb) { + /* Node v24 afterWrite runs `cb(null)` even for a destroyed + * stream (the destroy that landed after this tick was queued + * does not poison the completions it already owns), then + * errorBuffer. */ for (size_t i = 0; i < t->count; i++) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, NULL); if (scr_exc_pending()) break; } scr_closure_release(cb); @@ -8065,7 +8128,7 @@ static void scr_stream_run_tick(ScrStreamTick *t) { ScrClosure *cb = t->cb; t->cb = NULL; if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, t->err); scr_closure_release(cb); } break; @@ -8074,7 +8137,7 @@ static void scr_stream_run_tick(ScrStreamTick *t) { ScrClosure *cb = t->cb; t->cb = NULL; if (cb) { - ((void (*)(ScrClosure *))cb->fn)(cb); + SCR_WCB_CALL(cb, t->err); scr_closure_release(cb); } if (!scr_exc_pending()) scr_stream_error_or_destroy(s, t->err); diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 578712df2..960193261 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -420,6 +420,14 @@ ScrStream *scr_stream_end(ScrStream *s, ScrBytes *chunk_b, ScrStr *chunk_s, void scr_stream_cork(ScrStream *s); void scr_stream_uncork(ScrStream *s); +#ifdef SCR_STREAM_WRITE_ERRORS +/* An error-first write callback: wraps the user's zero-argument closure + * (which MOVES) so the compiler-built adapter (an ScrFsRenameFn, + * fn(cb, err-or-NULL)) receives the error this write completion carries + * (NULL is Node's null). */ +ScrClosure *scr_stream_ecb_wrap(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrError *)); +#endif + /* Shared lifecycle. destroy's err is BORROWED (may be NULL). */ ScrStream *scr_stream_destroy(ScrStream *s, ScrError *err); /* recv +1 */ /* Readable async-iterator return(): destroy with Node's AbortError payload; diff --git a/tests/corpus/writable-error-callbacks.ts b/tests/corpus/writable-error-callbacks.ts new file mode 100644 index 000000000..af0c66a4b --- /dev/null +++ b/tests/corpus/writable-error-callbacks.ts @@ -0,0 +1,53 @@ +// Writable.write completion callbacks that take the error argument: null +// after a successful write, the _write failure, ERR_STREAM_WRITE_AFTER_END +// after end(), and ERR_STREAM_DESTROYED after destroy(). Node orders the +// callback and the 'error' event through its writable state machine; this +// program prints both. +import { Writable } from "node:stream"; + +function show(label: string): (error?: Error | null) => void { + return (error) => { + const code = error ? (error as NodeJS.ErrnoException).code : undefined; + console.log( + label, + error === null ? "null" : error === undefined ? "undefined" : `${code ?? ""} ${error.message}`, + ); + }; +} + +const sink = new Writable({ + write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(chunk.toString() === "bad" ? new Error("rejected") : null); + }, +}); +sink.on("error", (error) => console.log("error event", error.message)); +sink.write("good", show("good")); +sink.write("bad", show("bad")); + +const ended = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +ended.on("error", (error) => + console.log("ended error event", (error as NodeJS.ErrnoException).code), +); +ended.write("first", show("first")); +ended.end(); +ended.write("late", show("late")); + +const destroyed = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +destroyed.destroy(); +destroyed.write("after destroy", show("destroyed")); + +// A callback declared without parameters keeps its current lowering. +const plain = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: () => void): void { + callback(); + }, +}); +plain.write("x", () => console.log("plain done")); diff --git a/tests/harness/corpus-costs/writable-error-callbacks.ts.json b/tests/harness/corpus-costs/writable-error-callbacks.ts.json new file mode 100644 index 000000000..e652520cd --- /dev/null +++ b/tests/harness/corpus-costs/writable-error-callbacks.ts.json @@ -0,0 +1 @@ +{ "plain": 4567, "san": 86151 } From bd06b809f1276c4fd11138db4fdb331ffd6570ac Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:28 -0700 Subject: [PATCH 3/7] Answer drain probes and drain listeners on the process streams (W1) Under the synchronous output model nothing is ever queued: Node emits 'drain' only after a write() returned false, and these writes never return false. The process streams therefore describe a stream whose writes complete synchronously, and the members a flush idiom probes are constants: - writableHighWaterMark is 65536 (Node's default for a pipe, a file and a TTY); - writableNeedDrain is false; - writableLength stays 0. `on`/`once`/`addListener`/`off`/`removeListener`/`prependListener`/ `prependOnceListener` of "drain" evaluate their receiver and listener and register nothing: the event cannot fire, and the listener table is not observable (listenerCount/listeners/eventNames on the process streams stay refused). `events.once(process.stdout | process.stderr, "drain")` lowers to `events.pendingForever`, a fresh promise that never settles, which is Node's behavior for a stream no write asked to drain; it holds no loop handle, so a program awaiting it still exits. The fallback declarations gain writableHighWaterMark, writableNeedDrain and the event methods, so typed programs can ask. Corpus: `tests/corpus/process-stream-drain.ts`; the diagnostics fixture `process-stream-listener-count.ts` pins that reading the listener table stays refused. Co-Authored-By: Claude Opus 5.5 --- .../console-and-process-streams.mdx | 1 + .../ambient/scriptc-node-fallback.d.ts | 12 +++ .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../src/frontend/lowering/builtins/process.ts | 84 ++++++++++++++++++- .../frontend/lowering/lower-event-emitter.ts | 20 +++++ .../tests/corpus/process-stream-drain.ts.json | 6 ++ .../process-stream-listener-count.ts.json | 18 ++++ tests/corpus/process-stream-drain.ts | 46 ++++++++++ .../process-stream-listener-count.ts | 5 ++ .../process-stream-listener-count.ts.txt | 8 ++ .../corpus-costs/process-stream-drain.ts.json | 1 + 11 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json create mode 100644 tests/corpus/process-stream-drain.ts create mode 100644 tests/diagnostics/process-stream-listener-count.ts create mode 100644 tests/harness/__snapshots__/process-stream-listener-count.ts.txt create mode 100644 tests/harness/corpus-costs/process-stream-drain.ts.json diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index a877512c1..8173d037e 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -16,6 +16,7 @@ JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, rep A replaced `write` method does not receive console's error-callback argument. Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. +Because no write is ever queued, `writableLength` is 0, `writableNeedDrain` is false, `writableHighWaterMark` is 65536, and `'drain'` never fires, so drain listeners register silently and `events.once(process.stdout, "drain")` stays pending, as it does in Node when no write returned false. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. A TypeScript `NodeJS.WritableStream` parameter that only receives `process.stdout` or `process.stderr` supports `write(string)`. When other streams flow into the same parameter, the process streams join them as `Writable` instances with the full writable surface. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..3cce98267 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1747,6 +1747,18 @@ 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; + /* Node's default for the process streams (65536 for a pipe, a file and a + * TTY): a constant, because nothing is ever queued. */ + readonly writableHighWaterMark: number; + /* Always false: a synchronous write never returns false, so no 'drain' + * is ever pending. */ + readonly writableNeedDrain: boolean; + /* 'drain' and the other events by name. Node returns the stream. */ + on(event: string, listener: (...args: any[]) => void): this; + once(event: string, listener: (...args: any[]) => void): this; + addListener(event: string, listener: (...args: any[]) => void): this; + off(event: string, listener: (...args: any[]) => void): this; + removeListener(event: string, listener: (...args: any[]) => void): this; getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9ed4e3a18..754b5eb33 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -381,6 +381,9 @@ export const LIB_FN_SYMS: Record = { "process.stdio": "scr_process_stdio", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", + // A promise that never settles (events.once of an event a synchronous + // stream never emits); a fresh pending promise is its owned result. + "events.pendingForever": "scr_promise_new", "date.now": "scr_date_now", "date.newNow": "scr_date_now", "date.newMs": "scr_date_new_ms", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 065183a69..87fd66412 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -442,7 +442,9 @@ export function lowerProcessStreamProperty( member !== "isTTY" && member !== "columns" && member !== "rows" && - member !== "writableLength" + member !== "writableLength" && + member !== "writableHighWaterMark" && + member !== "writableNeedDrain" ) return null; let recv: ts.Expression = expr.expression; @@ -476,19 +478,31 @@ export function lowerProcessStreamProperty( // nothing is ever queued and Node's number is always 0. The other // members stay keyed reads — the runtime stream answers their live // values (isatty columns/rows, stdin readableLength). - if (member === "writableLength") { + if ( + member === "writableLength" || + member === "writableHighWaterMark" || + member === "writableNeedDrain" + ) { // The receiver still EVALUATES: a call or property read that hands // back the stream runs its own side effects — and throws where Node // throws — before the constant answers, exactly as the read of the // member would. A pure receiver (the alias above) folds away. - const answer: IrExpr = { kind: "numLit", value: 0, type: F64, loc: locOf(expr) }; + const answer: IrExpr = + member === "writableNeedDrain" + ? { kind: "boolLit", value: false, type: BOOL, loc: locOf(expr) } + : { + kind: "numLit", + value: member === "writableLength" ? 0 : 65536, + type: F64, + loc: locOf(expr), + }; const effect = lowerer.lowerExpr(expr.expression); if (isSafeToDiscard(effect)) return answer; return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: effect, loc: locOf(expr.expression) }], result: answer, - type: F64, + type: answer.type, loc: locOf(expr), }; } @@ -520,6 +534,16 @@ export function lowerProcessStreamProperty( // true for the same reason). Backpressure probes read 0 and stay // correct; the write itself never defers. if (member === "writableLength") return { kind: "numLit", value: 0, type: F64, loc }; + // Node's default high-water mark for the process streams, and the + // drain flag a synchronous write never sets. + if (member === "writableHighWaterMark") { + if (stream === "stdin") return null; + return { kind: "numLit", value: 65536, type: F64, loc }; + } + if (member === "writableNeedDrain") { + if (stream === "stdin") return null; + return { kind: "boolLit", value: false, type: BOOL, loc }; + } if (member === "isTTY") { const type = lowerer.withUndefinedArm(BOOL); return { @@ -1000,6 +1024,58 @@ export function lowerProcessMethodCall( } return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc }; } + // process.stdout/stderr.on("drain", fn) and its spellings: Node emits + // 'drain' only after a write() returned false, and these writes are + // synchronous (they never return false), so the listener can never run. + // Registering it is therefore observably a no-op: the receiver and the + // listener are evaluated for their effects and nothing is retained. + // listenerCount/listeners/eventNames on the process streams stay + // refused, so the missing registration cannot be observed. + if ( + [ + "on", + "once", + "off", + "addListener", + "removeListener", + "prependListener", + "prependOnceListener", + ].includes(access.name.text) && + ts.isPropertyAccessExpression(access.expression) && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const stream = lowerer.stdlibGlobalMember(access.expression, "process"); + const evT = lowerer.typeOf(call.arguments.nodes[0]!); + if ( + (stream === "stdout" || stream === "stderr") && + evT.isStringLiteralType() && + evT.value === "drain" + ) { + const loc = locOf(call); + const parent = call.parent; + const discarded = + ts.isExpressionStatement(parent) || (ts.isArrowFunction(parent) && parent.body === call); + if (!discarded) { + lowerer.unsupported( + "SC1090", + call, + `using the result of process.${stream}.${access.name.text}("drain", ...) (register it as its own statement)`, + ); + } + const stmts: IrStmt[] = []; + for (const arg of call.arguments.nodes) { + const value = lowerer.lowerExprExpecting(arg, DYN); + if (!isSafeToDiscard(value)) stmts.push({ kind: "exprStmt", expr: value, loc: value.loc }); + } + // The call's value is discarded (statement position or a void arrow + // body), so the lowering is the evaluated arguments alone; a bare + // unitLit has no standalone IR value, so the no-op result is the + // dynamic undefined a discarded call leaves. + const result: IrExpr = dynUndefinedExpr(loc); + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: DYN, loc }; + } + } // process.stdout.on("resize", cb) (and once/off/removeListener/ // addListener, stderr too): the terminal resize event, registered on the // runtime's process stream object (a SIGWINCH listener that emits when a diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f44a3d6d8..be6ee9c75 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2532,6 +2532,26 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: const arrayT = arrayOf(DYN); const resultT: IrType = { kind: "promise", inner: arrayT }; let receiver = lowerer.lowerExpr(args[0]!); + // events.once(process.stdout, "drain"): the next 'drain' of a stream + // whose writes are synchronous never comes (Node emits it only after a + // write returned false), so the promise stays pending. A pending promise + // holds nothing: the process exits when the loop is empty, as in Node. + { + let target = args[0]!; + while (ts.isParenthesizedExpression(target)) target = target.expression; + const stream = ts.isPropertyAccessExpression(target) + ? lowerer.stdlibGlobalMember(target, "process") + : null; + const nameT = lowerer.typeOf(args[1]!); + if ( + (stream === "stdout" || stream === "stderr") && + nameT.isStringLiteralType() && + nameT.value === "drain" && + args.length === 2 + ) { + return { kind: "libCall", fn: "events.pendingForever", args: [], type: resultT, loc }; + } + } // A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream // unboxes by identity; any other value throws the not-supported Error. if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json new file mode 100644 index 000000000..95107f403 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-stream-drain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json new file mode 100644 index 000000000..3493be335 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/process-stream-listener-count.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Property 'listenerCount' does not exist on type 'WriteStream'.", + "loc": { + "file": "/tests/diagnostics/process-stream-listener-count.ts", + "start": 269, + "end": 282 + }, + "typeWorld": true, + "hint": "the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint" + } + ] +} diff --git a/tests/corpus/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts new file mode 100644 index 000000000..aa711edca --- /dev/null +++ b/tests/corpus/process-stream-drain.ts @@ -0,0 +1,46 @@ +// process.stdout/stderr backpressure probes under the synchronous stream +// model: every write reaches the descriptor before write() returns, so +// nothing is ever queued. writableLength is 0, writableNeedDrain is false, +// write() returns true, and 'drain' (emitted only after a write returned +// false) never fires: drain listeners register and stay silent, and a +// promise of the next drain never settles without holding the process. +import { once } from "node:events"; + +console.log("hwm", process.stdout.writableHighWaterMark, process.stderr.writableHighWaterMark); +console.log("needDrain", process.stdout.writableNeedDrain, process.stderr.writableNeedDrain); +console.log("length", process.stdout.writableLength, process.stderr.writableLength); + +let drained = 0; +const onDrain = (): void => { + drained++; +}; +process.stdout.on("drain", onDrain); +process.stdout.once("drain", () => { + drained++; +}); +process.stderr.addListener("drain", onDrain); +process.stdout.off("drain", onDrain); +process.stderr.removeListener("drain", onDrain); + +const ok = process.stdout.write("chunk\n"); +console.log("write returned", ok); + +// The flush idiom: wait for drain only when bytes are queued or the +// write asked for it. +async function flush(): Promise { + if (process.stdout.writableLength > 0) { + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + if (!process.stdout.write("again\n")) await once(process.stdout, "drain"); + console.log("flushed"); +} +void flush(); + +// A drain that cannot come: the promise stays pending, and the process +// exits once the loop is empty. +void (async () => { + await once(process.stderr, "drain"); + console.log("never"); +})(); + +setTimeout(() => console.log("drained", drained), 5); diff --git a/tests/diagnostics/process-stream-listener-count.ts b/tests/diagnostics/process-stream-listener-count.ts new file mode 100644 index 000000000..ca3ce5693 --- /dev/null +++ b/tests/diagnostics/process-stream-listener-count.ts @@ -0,0 +1,5 @@ +// Drain registration on the process streams lowers to nothing because a +// synchronous write never asks for a drain; the listener table it would +// fill is not observable, so reading it stays refused. +process.stdout.once("drain", () => {}); +console.log(process.stdout.listenerCount("drain")); diff --git a/tests/harness/__snapshots__/process-stream-listener-count.ts.txt b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt new file mode 100644 index 000000000..3bc0eb156 --- /dev/null +++ b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt @@ -0,0 +1,8 @@ +process-stream-listener-count.ts:5:28 - error SC0001: Property 'listenerCount' does not exist on type 'WriteStream'. + + 4 | process.stdout.once("drain", () => {}); + 5 | console.log(process.stdout.listenerCount("drain")); + | ^~~~~~~~~~~~~ + 6 | + + hint: the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint \ No newline at end of file diff --git a/tests/harness/corpus-costs/process-stream-drain.ts.json b/tests/harness/corpus-costs/process-stream-drain.ts.json new file mode 100644 index 000000000..283af8f7c --- /dev/null +++ b/tests/harness/corpus-costs/process-stream-drain.ts.json @@ -0,0 +1 @@ +{ "plain": 5912, "san": 124235 } From 7be1a990c79deff74fb81e5904322ba92daeef0f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:51:32 -0700 Subject: [PATCH 4/7] Write run-time-typed chunks to the process streams (W2) `process.stdout.write(data)`/`process.stderr.write(data)` with a chunk whose type is only known at run time (`any`, or a `string | Uint8Array` union) was refused. Lower the one-argument form to the new `process.stdioWriteDyn` libCall, and factor the existing stdio write method's dispatch into `scr_stdio_write_call` so both share Node's Writable validation: a string or a Uint8Array/Buffer writes its bytes, null throws ERR_STREAM_NULL_VALUES, and any other value throws ERR_INVALID_ARG_TYPE, all before anything is written. `scr_stdio_write_call` keeps its exact statements, so it is only the call sites that change; programs that contain a JS stdio value and link the events unit may differ (their write method's bytes). Corpus: `tests/corpus/process-write-dynamic-chunks.ts` covers strings, Buffer, Uint8Array, a string | Uint8Array union, a JSON field, and the four invalid chunks with their codes and messages. Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-internal.json | 8 ++ internal/compatibility/static-support.json | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/builtins/process.ts | 18 ++++ packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 7 ++ packages/compiler/src/ir/runtime-features.ts | 1 + .../process-write-dynamic-chunks.ts.json | 6 ++ packages/runtime/src/scr_blob.h | 4 + packages/runtime/src/scr_events.c | 92 ++++++++++++------- tests/corpus/process-write-dynamic-chunks.ts | 34 +++++++ .../process-write-dynamic-chunks.ts.json | 1 + 13 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json create mode 100644 tests/corpus/process-write-dynamic-chunks.ts create mode 100644 tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 928bea3bb..14a5a4d6a 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79679,6 +79679,7 @@ "tests/corpus/process-builtin-values.js", "tests/corpus/process-hrtime.js", "tests/corpus/process-named-signals.cjs", + "tests/corpus/process-stream-drain.ts", "tests/corpus/process-uncaught-handler-throws.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", @@ -79686,6 +79687,7 @@ "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-versions-storage.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdio-write-overrides.mjs", @@ -82049,6 +82051,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82092,6 +82096,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82135,6 +82141,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 96e4418db..5b4095f9a 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -314,7 +314,7 @@ { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, { "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] }, - { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/corpus/process-stream-drain.ts", "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts"] }, { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 754b5eb33..e7f918fdf 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record = { "process.envPairs": "scr_env_pairs", "process.stdoutWrite": "scr_process_stdout_write", "process.stdio": "scr_process_stdio", + "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", // A promise that never settles (events.once of an event a synchronous diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..7ef02a978 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -102,6 +102,7 @@ const INERT_LIB_CALLS = new Set([ "json.parse", "process.stderrWrite", "process.stdoutWrite", + "process.stdioWriteDyn", "process.envGet", "process.envPairs", "regex.groupEquals", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 87fd66412..e5c9bd5af 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -1357,6 +1357,24 @@ export function lowerProcessMethodCall( if (data.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(call))) { data = { kind: "dynCheck", value: data, type: STRING, loc }; } + // A chunk known only at run time (`any`, or a string | Uint8Array + // union): the runtime dispatches on the value — a string or a + // Uint8Array/Buffer writes, null throws ERR_STREAM_NULL_VALUES, any + // other value throws ERR_INVALID_ARG_TYPE, as Node's Writable.write + // does before writing anything. Only the one-argument form. + if ( + args.length === 1 && + (data.type.kind === "dyn" || data.type.kind === "union") && + (data.type.kind === "dyn" || lowerer.dynConvertible(data.type)) + ) { + return { + kind: "libCall", + fn: "process.stdioWriteDyn", + args: [numLit(stream === "stdout" ? 1 : 2, loc), lowerer.coerceToExpected(data, DYN)], + type: BOOL, + loc, + }; + } const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; if (!isBytes && data.type.kind !== "string") { lowerer.noLowering( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 114231730..85005cfc2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -107,6 +107,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "ffi.argument", "ffi.memoryModule", "process.stdoutWrite", + "process.stdioWriteDyn", "process.stderrWrite", "process.stdoutWriteBytes", "process.stderrWriteBytes", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 55596d087..d8d3ce999 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3668,6 +3668,9 @@ function buildLibFnSignatures() { * never throws. */ "process.stdoutWrite": { argTypes: [STRING], result: BOOL }, "process.stdio": { argTypes: [F64], result: DYN }, + /** process.stdout/stderr.write(chunk) with a run-time chunk: Node's + * Writable validation, then a synchronous write. */ + "process.stdioWriteDyn": { argTypes: [F64, DYN], result: BOOL }, "process.stderrWrite": { argTypes: [STRING], result: BOOL }, // error.new's result and the receiver slots are builtin-error classes — // program-dependent object types, checked in the libCall case. @@ -5341,6 +5344,10 @@ function buildLibFnSignatures() { /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, + /** A promise that never settles (events.once of an event that cannot + * occur, e.g. 'drain' on a synchronous process stream). The result is + * events.once's own Promise type. */ + "events.pendingForever": { argTypes: [], result: { kind: "promise", inner: arrayOf(DYN) } }, // Like process.envGet: the result is the module's interned // `number | undefined` union — checked by arms in the libCall case. /** Terminal width over an fd literal (1/2 — process.stdout/stderr diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 93b78db14..f922204d0 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..0558bbdd6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-write-dynamic-chunks.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 7c7893322..53d2d2228 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -68,6 +68,10 @@ ScrDyn *scr_console_new(double out_fd, double err_fd, bool ignore_errors); void scr_console_bind(ScrDyn *console, double which, ScrStream *stream); void scr_console_inst_write(ScrDyn *console, ScrStr *text, bool to_stderr); ScrDyn *scr_process_stdio(double fd); +/* process.stdout/stderr.write(chunk) with a run-time chunk: Node's Writable + * validation, then a synchronous write. false with an exception pending when + * the chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk); bool scr_dyn_process_stdio_is(const ScrDyn *value); double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 9de2c6ce2..8794c270c 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1311,6 +1311,52 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r } #endif + +/* stream.write(chunk[, encoding][, callback]) on a process output stream: + * Node's Writable.write validation, then a synchronous descriptor write. + * Returns +1 (true/false) or NULL with an exception pending. */ +static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *result = NULL; + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); + if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } + if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + return NULL; + } + if (arg->kind == SCR_DYN_NULL) { + const char *m = "May not write null values to stream"; + scr_throw_error_msg_code(SCR_ERR_TYPE, m, strlen(m), "ERR_STREAM_NULL_VALUES"); + return NULL; + } + if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem != SCR_BYTES_U8) + return scr_stdio_refusal("write of a typed array other than Uint8Array"); + ScrBytes *bytes = NULL; + if (arg->kind == SCR_DYN_STR) { + if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { + return scr_stdio_refusal("write encoding"); + } + ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + bytes = scr_bytes_from_str(arg->v.str, enc); + scr_str_release(enc); + } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { + bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); + } else { + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); + return NULL; + } + if (!bytes) return NULL; + int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); + scr_bytes_release(bytes); + if (error) { + scr_stdio_queue_write_error(stream, callback, error); + return scr_dyn_new_bool(false); + } + if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); + return scr_dyn_new_bool(true); +} + static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -1328,38 +1374,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_dyn_arr_push(result, columns); scr_dyn_arr_push(result, rows); } else if (method == STDIO_WRITE && stream->fd != 0) { - ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); - ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); - if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } - if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { - scr_dyn_arg_type_fail("cb", "of type function", callback); - goto done; - } - ScrBytes *bytes = NULL; - if (arg->kind == SCR_DYN_STR) { - if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { - result = scr_stdio_refusal("write encoding"); - goto done; - } - ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); - bytes = scr_bytes_from_str(arg->v.str, enc); - scr_str_release(enc); - } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { - bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); - } else { - scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); - goto done; - } - if (!bytes) goto done; - int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); - scr_bytes_release(bytes); - if (error) { - scr_stdio_queue_write_error(stream, callback, error); - result = scr_dyn_new_bool(false); - goto done; - } - if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); - result = scr_dyn_new_bool(true); + result = scr_stdio_write_call(stream, args, argc); } else if (method == STDIO_ON || method == STDIO_ONCE || method == STDIO_OFF) { ScrDyn *listener = argc > 1 ? args[1] : scr_dyn_undefined(); if (listener->kind != SCR_DYN_FUNC) { @@ -1597,6 +1612,19 @@ double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { return stream ? stream->fd : 0; } +/* process.stdout/stderr.write(chunk) with a run-time chunk: the stream + * method's own validation and write, without creating the stream value + * or installing the write hook. False with an exception pending when the + * chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk) { + ScrStdio *stream = &scr_stdio_streams[fd == 2 ? 2 : 1]; + ScrDyn *result = scr_stdio_write_call(stream, &chunk, 1); + if (!result) return false; + bool accepted = scr_dyn_truthy(result); + scr_dyn_release(result); + return accepted; +} + /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a * normal run (the unit's atexit, registered by scr_events_install after diff --git a/tests/corpus/process-write-dynamic-chunks.ts b/tests/corpus/process-write-dynamic-chunks.ts new file mode 100644 index 000000000..4356943b2 --- /dev/null +++ b/tests/corpus/process-write-dynamic-chunks.ts @@ -0,0 +1,34 @@ +// process.stdout/stderr.write of chunks whose type is only known at run +// time: `any` values (a child's 'data' chunk, a JSON field) and +// string | Uint8Array unions. Strings and Uint8Array/Buffer values write +// their bytes; null throws ERR_STREAM_NULL_VALUES and any other value +// throws ERR_INVALID_ARG_TYPE, both TypeErrors raised synchronously before +// anything is written. + +function forward(data: any): boolean { + return process.stdout.write(data); +} + +function pick(useBytes: boolean): string | Uint8Array { + return useBytes ? new Uint8Array([98, 121, 116, 101, 115, 10]) : "text\n"; +} + +console.log("string", forward("any string\n")); +console.log("buffer", forward(Buffer.from("any buffer\n"))); +console.log("u8", forward(new TextEncoder().encode("any u8\n"))); +process.stdout.write(pick(false)); +process.stdout.write(pick(true)); +process.stderr.write(pick(false)); +const parsed: any = JSON.parse('{"chunk":"from json\\n","n":42}'); +forward(parsed.chunk); + +for (const bad of [parsed.n, null, undefined, { a: 1 }]) { + try { + forward(bad); + console.log("no throw"); + } catch (error) { + if (error instanceof TypeError) { + console.log((error as NodeJS.ErrnoException).code, error.message); + } + } +} diff --git a/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..b83881cc0 --- /dev/null +++ b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 4567, "san": 86527 } From e386da4aa412a74dbb19b870417a5753fe524c0f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:50:25 -0700 Subject: [PATCH 5/7] Gate the typed stdio write slot out of the default events object The typed write slot, the drain probes and the run-time-typed chunk write all live in scr_events.c, a unit every program links. Unconditionally they changed the default object, so executables of programs that never touch the write slot changed byte for byte (three corpus programs differed runtime-only). Typed programs must stay byte-identical. Keep the default object exactly as it was and put the new code behind SCR_STDIO_WRITE_SLOT, the way SCR_STREAM_WRITE_ERRORS gates the stream unit: the shared write body, the run-time-typed chunk entry point and Node's anonymous write function exist only in that build, and scr_stdio_method keeps its own body everywhere else. The lowering sites that reach the slot (the write method as a value, its replacement, a patched program's typed writes) now emit a distinct marker lib call, process.stdioSlot, which is the same scr_process_stdio at run time. The feature scan turns it, together with process.stdioWriteDyn, into the stdioWriteSlot runtime feature, which selects the matching runtime pack variant and defines SCR_STDIO_WRITE_SLOT for the executable. The pack matrix enumerates the combinations with stdioExtended and nodeIsland explicitly, so each existing variant keeps its defines and its object. Both new lib fns join USES_TIMERS_LIB_FNS: the write callbacks they queue run from a nextTick, and a program whose only stdio use is the write slot would otherwise drop the callback. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 6 +++ .../src/backend/native-link-info-core.ts | 3 ++ .../compiler/src/backend/native/contracts.ts | 3 ++ .../compiler/src/backend/native/executable.ts | 2 + .../compiler/src/backend/runtime-pack-core.ts | 4 ++ .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/builtins/process.ts | 4 +- .../src/frontend/lowering/lower-stmts.ts | 2 +- packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ packages/compiler/src/ir/runtime-features.ts | 10 ++++ .../compiler/src/native/sanitizer.test.ts | 1 + .../runtime-darwin-arm64/scripts/build.mjs | 1 + .../runtime-pack-matrix.mjs | 23 +++++++++ .../runtime-pack-common/scripts/build.mjs | 1 + .../test/runtime-pack-matrix.test.ts | 32 ++++++++++++ packages/runtime/src/scr_events.c | 50 ++++++++++++++++++- 18 files changed, 147 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index f333aff7e..bbf3cdfff 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -21,6 +21,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), + ...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index e7f918fdf..694f8e8f0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record = { "process.envPairs": "scr_env_pairs", "process.stdoutWrite": "scr_process_stdout_write", "process.stdio": "scr_process_stdio", + "process.stdioSlot": "scr_process_stdio", "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", @@ -1869,6 +1870,11 @@ export const USES_TIMERS_LIB_FNS = new Set([ "worker.receiveMessageOnPort", "worker.broadcastChannel", "process.stdio", + // The typed write slot and the run-time-typed chunk write keep the loop + // live for the write callbacks they queue: both reach the same stream + // object as process.stdio and call back from a nextTick. + "process.stdioSlot", + "process.stdioWriteDyn", "fetch.start", "fetch.input", "fetch.function", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 54aa87b4d..43d4efa22 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -37,6 +37,9 @@ export interface NativeLinkFeatures { /** A write completion callback takes the error argument: links the * SCR_STREAM_WRITE_ERRORS build of the stream unit. */ streamWriteErrors?: boolean; + /** The program reaches the process output streams' write slot: links the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + stdioWriteSlot?: 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 a58d2310f..7a332e11f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -164,6 +164,9 @@ export interface CcOptions { /** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each * write completion callback receives (error-taking write callbacks). */ streamWriteErrors?: boolean; + /** SCR_STDIO_WRITE_SLOT: the events unit carries the typed write slot + * (the write method as a value, its replacement, run-time-typed chunks). */ + stdioWriteSlot?: 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 4d099a6d9..3ec34ca7c 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -412,6 +412,8 @@ const RUNTIME_DEFINES: readonly { // scr_stream.c's write-callback error publication, which the compiler's // error-first write adapter reads. { define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors }, + // scr_events.c's typed process output write slot. + { define: "SCR_STDIO_WRITE_SLOT", when: (opts) => opts.stdioWriteSlot }, // 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/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index cd22be057..a26e5e912 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -104,6 +104,9 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { /** The program's write completion callbacks take the error argument: * the SCR_STREAM_WRITE_ERRORS build of the stream unit. */ streamWriteErrors: boolean; + /** The program reaches the process output streams' write slot: the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + stdioWriteSlot: boolean; nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; @@ -316,6 +319,7 @@ export function effectiveRuntimeFeatures( warningReplace: features.warningReplace === true, stdioExtended: features.stdioExtended === true, streamWriteErrors: features.streamWriteErrors === true, + stdioWriteSlot: features.stdioWriteSlot === true, nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 36965eb89..702adc403 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -67,6 +67,8 @@ export interface EarlyExecutableNativeFeatures { stdioExtended?: boolean; /** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */ streamWriteErrors?: boolean; + /** The process output write slot: SCR_STDIO_WRITE_SLOT. */ + stdioWriteSlot?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; moduleNamespaceDyn?: boolean; diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index e5c9bd5af..3211994b9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -416,7 +416,7 @@ export function lowerProcessStreamProperty( kind: "dynKeyGet", value: { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(direct === "stdout" ? 1 : 2, loc)], type: DYN, loc, @@ -1242,7 +1242,7 @@ export function lowerProcessMethodCall( if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) { const handle: IrExpr = { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(stream === "stdout" ? 1 : 2, loc)], type: DYN, loc, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index cfea92522..c452beb94 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9214,7 +9214,7 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm args: [ { kind: "libCall", - fn: "process.stdio", + fn: "process.stdioSlot", args: [numLit(stream === "stdout" ? 1 : 2, loc)], type: DYN, loc, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index ecd6734a8..759871465 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -701,6 +701,7 @@ async function compileExecutableNative( ...(features.warningReplace ? { warningReplace: true } : {}), ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.streamWriteErrors ? { streamWriteErrors: true } : {}), + ...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index d8d3ce999..60fa80788 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3668,6 +3668,11 @@ function buildLibFnSignatures() { * never throws. */ "process.stdoutWrite": { argTypes: [STRING], result: BOOL }, "process.stdio": { argTypes: [F64], result: DYN }, + /** The same stream object, reached by the typed write-slot lowering + * (the write method as a value, its replacement, a patched program's + * typed writes): identical at run time, and the link switch for the + * SCR_STDIO_WRITE_SLOT build of the events unit. */ + "process.stdioSlot": { argTypes: [F64], result: DYN }, /** process.stdout/stderr.write(chunk) with a run-time chunk: Node's * Writable validation, then a synchronous write. */ "process.stdioWriteDyn": { argTypes: [F64, DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index f922204d0..43780b711 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioSlot", "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", @@ -1190,6 +1191,14 @@ const RUNTIME_FEATURE_RULES = { * through writable.writeEcb: the SCR_STREAM_WRITE_ERRORS stream unit * publishes the error the adapter delivers. */ streamWriteErrors: rule({ fns: ["writable.writeEcb"] }), + /** The program reaches the process output streams' write slot — the + * write method as a value (process.stdioSlot), its replacement, a patched + * program's typed writes, or a run-time-typed chunk + * (process.stdioWriteDyn): the SCR_STDIO_WRITE_SLOT build of the events + * unit carries the shared write body, the dynamic-chunk entry point and + * Node's anonymous write function. Every other program links the default + * object, byte for byte. */ + stdioWriteSlot: rule({ fns: ["process.stdioSlot", "process.stdioWriteDyn"] }), stream: rule({ initial: (mod) => (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), fns: [...STREAM_LIB_FNS, "dyn.builtinStreamClass"], @@ -1314,6 +1323,7 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + stdioWriteSlot: false, streamWriteErrors: false, moduleNamespace: false, moduleNamespaceDyn: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 0e7bccafd..faea14004 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -33,6 +33,7 @@ const features = { warningReplace: false, stdioExtended: false, streamWriteErrors: false, + stdioWriteSlot: false, nodeIsland: false, brotli: false, moduleNamespace: false, diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index 24bde2db5..3e7c8930a 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -377,6 +377,7 @@ async function build() { "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", "SCR_STREAM_WRITE_ERRORS", + "SCR_STDIO_WRITE_SLOT", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 5e3b722d2..630a261b8 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -324,6 +324,29 @@ export function variantsFor(source) { when: { stdioExtended: true, nodeIsland: true }, defines: ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"], }); + // The typed process output write slot (the write method as a value, its + // replacement, run-time-typed chunks): the shared write body, the + // dynamic-chunk entry point and Node's anonymous write function. Every + // other program keeps the default object, byte for byte. + for (const [id, when, defines] of [ + ["stdio-write-slot", { stdioWriteSlot: true }, ["SCR_STDIO_WRITE_SLOT"]], + [ + "stdio-extended-write-slot", + { stdioExtended: true, stdioWriteSlot: true }, + ["SCR_STDIO_EXTENDED", "SCR_STDIO_WRITE_SLOT"], + ], + [ + "node-island-write-slot", + { nodeIsland: true, stdioWriteSlot: true }, + ["SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"], + ], + [ + "stdio-extended-node-island-write-slot", + { stdioExtended: true, nodeIsland: true, stdioWriteSlot: true }, + ["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"], + ], + ]) + variants.push({ id, when, defines }); } // 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-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index e8af727ea..9d58f77fd 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -546,6 +546,7 @@ async function build() { "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED", "SCR_STREAM_WRITE_ERRORS", + "SCR_STDIO_WRITE_SLOT", "SCR_NATIVE_REACTIONS", "SCR_HANDLE_CLASSES", "SCR_YIN", diff --git a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts index 9157d7bd0..a0107b78b 100644 --- a/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts +++ b/packages/runtime-pack-common/test/runtime-pack-matrix.test.ts @@ -24,6 +24,38 @@ function selected( return matches[0]; } +test("the typed stdio write slot selects the events build that carries it", () => { + const plain = { stdioExtended: false, nodeIsland: false, workers: false }; + // Alone, the write slot selects its own build. + expect(selected("scr_events.c", { ...plain, stdioWriteSlot: true }).defines).toEqual([ + "SCR_STDIO_WRITE_SLOT", + ]); + // Beside the other events features it keeps each one's defines. + expect( + selected("scr_events.c", { ...plain, stdioExtended: true, stdioWriteSlot: true }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_STDIO_WRITE_SLOT"]); + expect( + selected("scr_events.c", { ...plain, nodeIsland: true, stdioWriteSlot: true }).defines, + ).toEqual(["SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"]); + expect( + selected("scr_events.c", { + ...plain, + stdioExtended: true, + nodeIsland: true, + stdioWriteSlot: true, + }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS", "SCR_STDIO_WRITE_SLOT"]); + // A program that never reaches the write slot keeps the old object. + expect( + selected("scr_events.c", { + ...plain, + stdioExtended: true, + nodeIsland: true, + stdioWriteSlot: false, + }).defines, + ).toEqual(["SCR_STDIO_EXTENDED", "SCR_NODE_ISLANDS"]); +}); + test("web streams and Node islands select an async build that defines both", () => { const variant = selected("scr_async.c", { webStreams: true, nodeIsland: true, workers: false }); expect(variant.defines).toEqual( diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 8794c270c..e73515f66 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1312,9 +1312,13 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r #endif +#ifdef SCR_STDIO_WRITE_SLOT /* stream.write(chunk[, encoding][, callback]) on a process output stream: * Node's Writable.write validation, then a synchronous descriptor write. - * Returns +1 (true/false) or NULL with an exception pending. */ + * Returns +1 (true/false) or NULL with an exception pending. The typed + * write-slot build factors it out of scr_stdio_method so the dynamic-chunk + * entry point below shares it; every other build keeps the method's own + * body, byte for byte. */ static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_t argc) { ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); ScrDyn *result = NULL; @@ -1356,6 +1360,7 @@ static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_ if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); return scr_dyn_new_bool(true); } +#endif static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); @@ -1374,7 +1379,42 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_dyn_arr_push(result, columns); scr_dyn_arr_push(result, rows); } else if (method == STDIO_WRITE && stream->fd != 0) { +#ifdef SCR_STDIO_WRITE_SLOT result = scr_stdio_write_call(stream, args, argc); +#else + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); + if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } + if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + goto done; + } + ScrBytes *bytes = NULL; + if (arg->kind == SCR_DYN_STR) { + if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { + result = scr_stdio_refusal("write encoding"); + goto done; + } + ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + bytes = scr_bytes_from_str(arg->v.str, enc); + scr_str_release(enc); + } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { + bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); + } else { + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); + goto done; + } + if (!bytes) goto done; + int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); + scr_bytes_release(bytes); + if (error) { + scr_stdio_queue_write_error(stream, callback, error); + result = scr_dyn_new_bool(false); + goto done; + } + if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); + result = scr_dyn_new_bool(true); +#endif } else if (method == STDIO_ON || method == STDIO_ONCE || method == STDIO_OFF) { ScrDyn *listener = argc > 1 ? args[1] : scr_dyn_undefined(); if (listener->kind != SCR_DYN_FUNC) { @@ -1595,8 +1635,14 @@ ScrDyn *scr_process_stdio(double fd) { ScrClosure *cb = scr_closure_new(NULL, 1); cb->caps[0] = scr_box_new(SCR_BOX_F64); scr_box_set_f64(cb->caps[0], i); +#ifdef SCR_STDIO_WRITE_SLOT + // Node's process.stdout.write is an anonymous (bound) function: + // .name is "" there, while the other stream methods carry their names. scr_stdio_methods[i] = scr_dyn_new_func( cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", i == STDIO_WRITE ? "" : scr_stdio_names[i]); +#else + scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]); +#endif } scr_stdio_write_hook = scr_stdio_intercept; } @@ -1612,6 +1658,7 @@ double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { return stream ? stream->fd : 0; } +#ifdef SCR_STDIO_WRITE_SLOT /* process.stdout/stderr.write(chunk) with a run-time chunk: the stream * method's own validation and write, without creating the stream value * or installing the write hook. False with an exception pending when the @@ -1624,6 +1671,7 @@ bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk) { scr_dyn_release(result); return accepted; } +#endif /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a From f6f1844b1a4e4a5203e0f7590ff17a540ce9997f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:50:25 -0700 Subject: [PATCH 6/7] Update the write-callback refusal snapshot Error-taking write completion callbacks lower now (W3), so the diagnostics corpus no longer reports SC2020 for them. The end-callback refusal it pins is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../stream-completion-callback-error.ts.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt index f3b876cc7..0e948a3f1 100644 --- a/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt +++ b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt @@ -1,12 +1,3 @@ -stream-completion-callback-error.ts:10:17 - error SC2020: 'write completion callbacks taking the error argument' is part of the standard library types but has no scriptc lowering yet - - 9 | }); - 10 | sink.write("a", (err) => console.log("written", err)); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 11 | sink.end((err?: Error | null) => console.log("ended", err)); - - hint: () => void callbacks are supported (listen for 'error' to observe failures) - stream-completion-callback-error.ts:11:10 - error SC2020: 'end callbacks with parameters' is part of the standard library types but has no scriptc lowering yet 10 | sink.write("a", (err) => console.log("written", err)); From b8e678b765b0fac318c5b20e3931457bc5896ebc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 14:48:45 -0500 Subject: [PATCH 7/7] Evaluate a drain name expression, and discard a write callback's return value events.once(process.stdout, name) with a name typed "drain" returned the pending promise without evaluating the name, so a call or comma expression producing "drain" never ran. A name that is not a string literal now runs before the promise is created. A write completion callback that returns a value (`() => count++`, a stored function typed `(): number`) reported SC2020. The stream ignores the return value, so such a callback is adapted to return nothing, as before the error-argument form was added. The stream-shims npm case compiles and matches Node now that write callbacks take the error argument, so its SC2020 expectation is removed. --- .../frontend/lowering/lower-event-emitter.ts | 19 ++++++++++++++++++- .../src/frontend/lowering/lower-stream.ts | 8 +++++++- tests/corpus/process-stream-drain.ts | 10 ++++++++++ tests/corpus/writable-error-callbacks.ts | 13 +++++++++++++ tests/harness/npm-suite.ts | 1 - 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index be6ee9c75..b9c71715e 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2549,7 +2549,24 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: nameT.value === "drain" && args.length === 2 ) { - return { kind: "libCall", fn: "events.pendingForever", args: [], type: resultT, loc }; + const pending: IrExpr = { + kind: "libCall", + fn: "events.pendingForever", + args: [], + type: resultT, + loc, + }; + let name = args[1]!; + while (ts.isParenthesizedExpression(name)) name = name.expression; + if (ts.isStringLiteralLike(name)) return pending; + // A name expression typed "drain" still runs (`pick()`, `(log(), "drain")`). + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(args[1]!), loc }], + result: pending, + type: resultT, + loc, + }; } } // A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 6d203ae7f..5da73ed0a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6964,7 +6964,13 @@ export function lowerStreamMethodCall( const kind = chunkKind(args[0]!); if (kind === "null") lowerer.noLowering("write(null)", call, "end() finishes a writable"); const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { - const cb = lowerer.lowerExpr(cbArg); + // The stream ignores what the callback returns (`() => count++`, a + // stored function returning a value): the value is discarded. + const raw = lowerer.lowerExpr(cbArg); + const cb = + raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 + ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) + : raw; if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( "write completion callbacks of this shape", diff --git a/tests/corpus/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts index aa711edca..24bd995f7 100644 --- a/tests/corpus/process-stream-drain.ts +++ b/tests/corpus/process-stream-drain.ts @@ -43,4 +43,14 @@ void (async () => { console.log("never"); })(); +// A name expression typed "drain" still runs. +let picks = 0; +const drainName = (): "drain" => { + picks++; + return "drain"; +}; +void once(process.stdout, drainName()); +void once(process.stderr, (picks++, "drain")); +console.log("picks", picks); + setTimeout(() => console.log("drained", drained), 5); diff --git a/tests/corpus/writable-error-callbacks.ts b/tests/corpus/writable-error-callbacks.ts index af0c66a4b..0eaf0c33f 100644 --- a/tests/corpus/writable-error-callbacks.ts +++ b/tests/corpus/writable-error-callbacks.ts @@ -51,3 +51,16 @@ const plain = new Writable({ }, }); plain.write("x", () => console.log("plain done")); + +// Callbacks that return a value: the stream discards it. +let count = 0; +const counted = (): number => ++count; +plain.write("y", counted); +const tally = (error?: Error | null): number => (error ? -1 : ++count); +const counting = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(null); + }, +}); +counting.write("tally", tally); +setTimeout(() => console.log("count", count), 5); diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index d31d96ac9..5457ab7f3 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -107,7 +107,6 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC2020" }, "util-shims": { run: "SC2020" }, // The package redeclares the var it exported: Node copies the first value. "var-export-redeclared": { island: true },