diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index a877512c1..8173d037e 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -16,6 +16,7 @@ JavaScript and TypeScript can store `process.stdin`, `stdout`, and `stderr`, rep A replaced `write` method does not receive console's error-callback argument. Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. +Because no write is ever queued, `writableLength` is 0, `writableNeedDrain` is false, `writableHighWaterMark` is 65536, and `'drain'` never fires, so drain listeners register silently and `events.once(process.stdout, "drain")` stays pending, as it does in Node when no write returned false. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. A TypeScript `NodeJS.WritableStream` parameter that only receives `process.stdout` or `process.stderr` supports `write(string)`. When other streams flow into the same parameter, the process streams join them as `Writable` instances with the full writable surface. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index edb391f7d..3cce98267 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1747,6 +1747,18 @@ declare module "tty" { /* The bytes queued behind the last write: the compiled binary's fd * 1/2 writes are synchronous, so it is always 0. */ readonly writableLength: number; + /* Node's default for the process streams (65536 for a pipe, a file and a + * TTY): a constant, because nothing is ever queued. */ + readonly writableHighWaterMark: number; + /* Always false: a synchronous write never returns false, so no 'drain' + * is ever pending. */ + readonly writableNeedDrain: boolean; + /* 'drain' and the other events by name. Node returns the stream. */ + on(event: string, listener: (...args: any[]) => void): this; + once(event: string, listener: (...args: any[]) => void): this; + addListener(event: string, listener: (...args: any[]) => void): this; + off(event: string, listener: (...args: any[]) => void): this; + removeListener(event: string, listener: (...args: any[]) => void): this; getWindowSize(): [number, number]; } export function isatty(fd: number): boolean; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9ed4e3a18..754b5eb33 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -381,6 +381,9 @@ export const LIB_FN_SYMS: Record = { "process.stdio": "scr_process_stdio", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", + // A promise that never settles (events.once of an event a synchronous + // stream never emits); a fresh pending promise is its owned result. + "events.pendingForever": "scr_promise_new", "date.now": "scr_date_now", "date.newNow": "scr_date_now", "date.newMs": "scr_date_new_ms", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 065183a69..87fd66412 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -442,7 +442,9 @@ export function lowerProcessStreamProperty( member !== "isTTY" && member !== "columns" && member !== "rows" && - member !== "writableLength" + member !== "writableLength" && + member !== "writableHighWaterMark" && + member !== "writableNeedDrain" ) return null; let recv: ts.Expression = expr.expression; @@ -476,19 +478,31 @@ export function lowerProcessStreamProperty( // nothing is ever queued and Node's number is always 0. The other // members stay keyed reads — the runtime stream answers their live // values (isatty columns/rows, stdin readableLength). - if (member === "writableLength") { + if ( + member === "writableLength" || + member === "writableHighWaterMark" || + member === "writableNeedDrain" + ) { // The receiver still EVALUATES: a call or property read that hands // back the stream runs its own side effects — and throws where Node // throws — before the constant answers, exactly as the read of the // member would. A pure receiver (the alias above) folds away. - const answer: IrExpr = { kind: "numLit", value: 0, type: F64, loc: locOf(expr) }; + const answer: IrExpr = + member === "writableNeedDrain" + ? { kind: "boolLit", value: false, type: BOOL, loc: locOf(expr) } + : { + kind: "numLit", + value: member === "writableLength" ? 0 : 65536, + type: F64, + loc: locOf(expr), + }; const effect = lowerer.lowerExpr(expr.expression); if (isSafeToDiscard(effect)) return answer; return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: effect, loc: locOf(expr.expression) }], result: answer, - type: F64, + type: answer.type, loc: locOf(expr), }; } @@ -520,6 +534,16 @@ export function lowerProcessStreamProperty( // true for the same reason). Backpressure probes read 0 and stay // correct; the write itself never defers. if (member === "writableLength") return { kind: "numLit", value: 0, type: F64, loc }; + // Node's default high-water mark for the process streams, and the + // drain flag a synchronous write never sets. + if (member === "writableHighWaterMark") { + if (stream === "stdin") return null; + return { kind: "numLit", value: 65536, type: F64, loc }; + } + if (member === "writableNeedDrain") { + if (stream === "stdin") return null; + return { kind: "boolLit", value: false, type: BOOL, loc }; + } if (member === "isTTY") { const type = lowerer.withUndefinedArm(BOOL); return { @@ -1000,6 +1024,58 @@ export function lowerProcessMethodCall( } return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc }; } + // process.stdout/stderr.on("drain", fn) and its spellings: Node emits + // 'drain' only after a write() returned false, and these writes are + // synchronous (they never return false), so the listener can never run. + // Registering it is therefore observably a no-op: the receiver and the + // listener are evaluated for their effects and nothing is retained. + // listenerCount/listeners/eventNames on the process streams stay + // refused, so the missing registration cannot be observed. + if ( + [ + "on", + "once", + "off", + "addListener", + "removeListener", + "prependListener", + "prependOnceListener", + ].includes(access.name.text) && + ts.isPropertyAccessExpression(access.expression) && + call.arguments.nodes.length === 2 && + !call.arguments.nodes.some(ts.isSpreadElement) + ) { + const stream = lowerer.stdlibGlobalMember(access.expression, "process"); + const evT = lowerer.typeOf(call.arguments.nodes[0]!); + if ( + (stream === "stdout" || stream === "stderr") && + evT.isStringLiteralType() && + evT.value === "drain" + ) { + const loc = locOf(call); + const parent = call.parent; + const discarded = + ts.isExpressionStatement(parent) || (ts.isArrowFunction(parent) && parent.body === call); + if (!discarded) { + lowerer.unsupported( + "SC1090", + call, + `using the result of process.${stream}.${access.name.text}("drain", ...) (register it as its own statement)`, + ); + } + const stmts: IrStmt[] = []; + for (const arg of call.arguments.nodes) { + const value = lowerer.lowerExprExpecting(arg, DYN); + if (!isSafeToDiscard(value)) stmts.push({ kind: "exprStmt", expr: value, loc: value.loc }); + } + // The call's value is discarded (statement position or a void arrow + // body), so the lowering is the evaluated arguments alone; a bare + // unitLit has no standalone IR value, so the no-op result is the + // dynamic undefined a discarded call leaves. + const result: IrExpr = dynUndefinedExpr(loc); + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: DYN, loc }; + } + } // process.stdout.on("resize", cb) (and once/off/removeListener/ // addListener, stderr too): the terminal resize event, registered on the // runtime's process stream object (a SIGWINCH listener that emits when a diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f44a3d6d8..be6ee9c75 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2532,6 +2532,26 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: const arrayT = arrayOf(DYN); const resultT: IrType = { kind: "promise", inner: arrayT }; let receiver = lowerer.lowerExpr(args[0]!); + // events.once(process.stdout, "drain"): the next 'drain' of a stream + // whose writes are synchronous never comes (Node emits it only after a + // write returned false), so the promise stays pending. A pending promise + // holds nothing: the process exits when the loop is empty, as in Node. + { + let target = args[0]!; + while (ts.isParenthesizedExpression(target)) target = target.expression; + const stream = ts.isPropertyAccessExpression(target) + ? lowerer.stdlibGlobalMember(target, "process") + : null; + const nameT = lowerer.typeOf(args[1]!); + if ( + (stream === "stdout" || stream === "stderr") && + nameT.isStringLiteralType() && + nameT.value === "drain" && + args.length === 2 + ) { + return { kind: "libCall", fn: "events.pendingForever", args: [], type: resultT, loc }; + } + } // A checked-dynamic NodeJS stream slot (dyn-streams.ts): a native stream // unboxes by identity; any other value throws the not-supported Error. if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json new file mode 100644 index 000000000..95107f403 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-stream-drain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-stream-drain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json new file mode 100644 index 000000000..3493be335 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/process-stream-listener-count.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/process-stream-listener-count.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Property 'listenerCount' does not exist on type 'WriteStream'.", + "loc": { + "file": "/tests/diagnostics/process-stream-listener-count.ts", + "start": 269, + "end": 282 + }, + "typeWorld": true, + "hint": "the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint" + } + ] +} diff --git a/tests/corpus/process-stream-drain.ts b/tests/corpus/process-stream-drain.ts new file mode 100644 index 000000000..aa711edca --- /dev/null +++ b/tests/corpus/process-stream-drain.ts @@ -0,0 +1,46 @@ +// process.stdout/stderr backpressure probes under the synchronous stream +// model: every write reaches the descriptor before write() returns, so +// nothing is ever queued. writableLength is 0, writableNeedDrain is false, +// write() returns true, and 'drain' (emitted only after a write returned +// false) never fires: drain listeners register and stay silent, and a +// promise of the next drain never settles without holding the process. +import { once } from "node:events"; + +console.log("hwm", process.stdout.writableHighWaterMark, process.stderr.writableHighWaterMark); +console.log("needDrain", process.stdout.writableNeedDrain, process.stderr.writableNeedDrain); +console.log("length", process.stdout.writableLength, process.stderr.writableLength); + +let drained = 0; +const onDrain = (): void => { + drained++; +}; +process.stdout.on("drain", onDrain); +process.stdout.once("drain", () => { + drained++; +}); +process.stderr.addListener("drain", onDrain); +process.stdout.off("drain", onDrain); +process.stderr.removeListener("drain", onDrain); + +const ok = process.stdout.write("chunk\n"); +console.log("write returned", ok); + +// The flush idiom: wait for drain only when bytes are queued or the +// write asked for it. +async function flush(): Promise { + if (process.stdout.writableLength > 0) { + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + if (!process.stdout.write("again\n")) await once(process.stdout, "drain"); + console.log("flushed"); +} +void flush(); + +// A drain that cannot come: the promise stays pending, and the process +// exits once the loop is empty. +void (async () => { + await once(process.stderr, "drain"); + console.log("never"); +})(); + +setTimeout(() => console.log("drained", drained), 5); diff --git a/tests/diagnostics/process-stream-listener-count.ts b/tests/diagnostics/process-stream-listener-count.ts new file mode 100644 index 000000000..ca3ce5693 --- /dev/null +++ b/tests/diagnostics/process-stream-listener-count.ts @@ -0,0 +1,5 @@ +// Drain registration on the process streams lowers to nothing because a +// synchronous write never asks for a drain; the listener table it would +// fill is not observable, so reading it stays refused. +process.stdout.once("drain", () => {}); +console.log(process.stdout.listenerCount("drain")); diff --git a/tests/harness/__snapshots__/process-stream-listener-count.ts.txt b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt new file mode 100644 index 000000000..3bc0eb156 --- /dev/null +++ b/tests/harness/__snapshots__/process-stream-listener-count.ts.txt @@ -0,0 +1,8 @@ +process-stream-listener-count.ts:5:28 - error SC0001: Property 'listenerCount' does not exist on type 'WriteStream'. + + 4 | process.stdout.once("drain", () => {}); + 5 | console.log(process.stdout.listenerCount("drain")); + | ^~~~~~~~~~~~~ + 6 | + + hint: the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — 'listenerCount' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint \ No newline at end of file diff --git a/tests/harness/corpus-costs/process-stream-drain.ts.json b/tests/harness/corpus-costs/process-stream-drain.ts.json new file mode 100644 index 000000000..283af8f7c --- /dev/null +++ b/tests/harness/corpus-costs/process-stream-drain.ts.json @@ -0,0 +1 @@ +{ "plain": 5912, "san": 124235 }