diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index 946cb8061..8173d037e 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -12,9 +12,11 @@ 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. +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/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index a93e7b34c..5e5cc8c4c 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79697,6 +79697,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", @@ -79704,9 +79705,11 @@ "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", + "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", @@ -82065,6 +82068,9 @@ "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" ] }, @@ -82107,6 +82113,9 @@ "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" ] }, @@ -82149,6 +82158,9 @@ "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" ] }, @@ -86951,7 +86963,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" @@ -87077,7 +87090,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" @@ -87111,7 +87125,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" @@ -87145,7 +87160,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" @@ -87202,7 +87218,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" @@ -87236,7 +87253,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" @@ -87270,7 +87288,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" @@ -87666,7 +87685,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" @@ -87723,7 +87743,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" @@ -87757,7 +87778,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" @@ -87814,7 +87836,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" @@ -87848,7 +87871,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" @@ -87882,7 +87906,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" @@ -88278,7 +88303,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" @@ -88381,7 +88407,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" @@ -88441,7 +88468,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" @@ -88475,7 +88503,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" @@ -88509,7 +88538,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" @@ -88566,7 +88596,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" @@ -89159,7 +89190,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" @@ -89193,7 +89225,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" @@ -89227,7 +89260,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" @@ -89261,7 +89295,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" @@ -89295,7 +89330,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" @@ -89329,7 +89365,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" @@ -89363,7 +89400,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" @@ -89397,7 +89435,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" @@ -89431,7 +89470,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" @@ -90850,7 +90890,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" @@ -91114,7 +91155,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" @@ -91148,7 +91190,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 8afe8af22..3918a8941 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/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"] }, @@ -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/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 7a7b61fd7..70a849871 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1764,6 +1764,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/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index a17c7398c..8a67fe927 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,8 @@ 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.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 453e45f6b..8f220df71 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -391,9 +391,14 @@ 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", "process.isTTYValue": "scr_process_is_tty_value", + // A promise that never settles (events.once of an event a synchronous + // stream never emits); a fresh pending promise is its owned result. + "events.pendingForever": "scr_promise_new", "date.now": "scr_date_now", "date.newNow": "scr_date_now", "date.newMs": "scr_date_new_ms", @@ -1914,6 +1919,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", @@ -1999,6 +2009,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/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index abae9032e..e02ae5cf8 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -103,6 +103,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/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 71c4c0364..969ee08e5 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -34,6 +34,12 @@ 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; + /** 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 c3ac94ce7..bd442abe8 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -161,6 +161,12 @@ 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; + /** 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 48c2e1b5e..e4e57684c 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -425,6 +425,11 @@ 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_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 b5005991c..bbc9ce0fd 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -101,6 +101,12 @@ 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; + /** 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; @@ -313,6 +319,8 @@ export function effectiveRuntimeFeatures( computedRequire: features.computedRequire === true, 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/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 4843c24ec..a5f4fdbe2 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -370,6 +370,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/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 401e8edbe..3a8e834a6 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -65,6 +65,10 @@ export interface EarlyExecutableNativeFeatures { wasm?: boolean; warningReplace?: boolean; 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/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 614e60155..a85e17f37 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -8,6 +8,7 @@ import { npmStaticRefusalDiag, iceDiag, isCheckerPanic, + stdioPatchAcrossIslandsDiag, type ScrDiagnostic, } from "../diagnostics/diagnostic.js"; import type { IrModule, SrcLoc } from "../ir/ir.js"; @@ -202,6 +203,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 8654770ba..2b2a05208 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.stdioSlot", + 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. @@ -357,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; @@ -391,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), }; } @@ -435,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 { @@ -920,6 +1029,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 @@ -1078,6 +1239,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.stdioSlot", + 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`, @@ -1174,6 +1362,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/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index be9de0e5a..92cc7f23d 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2557,6 +2557,43 @@ 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 + ) { + 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 // unboxes by identity; any other value throws the not-supported Error. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 4ff95b6d8..b6e707ee3 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,7 +32,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"; @@ -9333,6 +9333,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.stdioSlot", + 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/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index d8c370511..5da73ed0a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6964,18 +6964,45 @@ 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) { + // 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 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 +7015,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 +7094,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/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d28ed72fe..a1842e1ac 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -104,6 +104,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"; @@ -960,6 +962,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 { @@ -2681,6 +2688,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[] = []; @@ -4509,6 +4522,8 @@ export class Lowerer { "unixListenReachable", "streamPropMutatedSyms", "readsExitCode", + "patchesStdio", + "stdioPatchSites", "requestInitPropMutatedSyms", "classProtocols", // The whole-program prepass analyzeJsWidenedBindings computes this set @@ -9887,11 +9902,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 139eba12b..6298c939f 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5197,6 +5197,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 @@ -5611,6 +5617,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/src/index.ts b/packages/compiler/src/index.ts index 3c6b96bdf..b39531eeb 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -707,6 +707,8 @@ async function compileExecutableNative( ...(features.wasm ? { wasm: true } : {}), ...(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-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 248577278..5a2a4ab19 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", @@ -443,6 +444,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 ebc9e322a..91da1eb6b 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3763,6 +3763,14 @@ 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 }, "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. @@ -4671,6 +4679,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 }, @@ -5433,6 +5445,10 @@ function buildLibFnSignatures() { * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, "process.isTTYValue": { argTypes: [DYN], result: BOOL }, + /** A promise that never settles (events.once of an event that cannot + * occur, e.g. 'drain' on a synchronous process stream). The result is + * events.once's own Promise type. */ + "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 e8af1b608..fd4f20614 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,8 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioSlot", + "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", @@ -1208,6 +1210,18 @@ 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"] }), + /** 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"], @@ -1336,6 +1350,8 @@ function noRuntimeFeatures(): RuntimeFeatures { searchParams: false, urlIdna: false, stdioExtended: false, + stdioWriteSlot: false, + streamWriteErrors: false, moduleNamespace: false, moduleNamespaceDyn: false, primitiveWrapper: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 2d65d5206..6ab87a2bd 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -32,6 +32,8 @@ const features = { computedRequire: false, warningReplace: false, stdioExtended: false, + streamWriteErrors: false, + stdioWriteSlot: false, nodeIsland: false, brotli: false, moduleNamespace: false, 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/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/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/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/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/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/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index ecb2a901b..3e7c8930a 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -376,6 +376,8 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "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 2096828d4..95d3f3f8c 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -351,6 +351,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. @@ -361,6 +384,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..9d58f77fd 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -545,6 +545,8 @@ async function build() { "SCR_TEXT_DECODER_LEGACY", "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 4d6495738..82b6d9e12 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_blob.h b/packages/runtime/src/scr_blob.h index ace652f13..0b24aff98 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -69,6 +69,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 9b918061d..349a1e3c1 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1311,6 +1311,57 @@ 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. 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; + 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); +} +#endif + 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,6 +1379,9 @@ 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(); } @@ -1360,6 +1414,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } 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) { @@ -1605,7 +1660,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_console_handler = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_stdio_console_error_handler, 1, "", ""); scr_stdio_write_hook = scr_stdio_intercept; @@ -1622,6 +1684,21 @@ 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 + * 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; +} +#endif + /* ── 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/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index c055d9fa0..a029ece82 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -2200,6 +2200,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 @@ -2213,7 +2266,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); } } @@ -2256,7 +2309,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); @@ -7169,7 +7222,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; @@ -7421,7 +7480,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); @@ -8010,8 +8069,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); @@ -8105,7 +8168,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; @@ -8114,7 +8177,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 2ae392258..f5a8207f2 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/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts new file mode 100644 index 000000000..24bd995f7 --- /dev/null +++ b/tests/corpus/process-stream-drain.ts @@ -0,0 +1,56 @@ +// 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"); +})(); + +// 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/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/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/corpus/writable-error-callbacks.ts b/tests/corpus/writable-error-callbacks.ts new file mode 100644 index 000000000..0eaf0c33f --- /dev/null +++ b/tests/corpus/writable-error-callbacks.ts @@ -0,0 +1,66 @@ +// 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")); + +// 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/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/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__/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/__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/__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)); 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 } 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 } 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 } 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 } diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index 7d0d7c8b0..4c33ac03f 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -116,7 +116,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 },