Answer drain probes and drain listeners on the process streams (W1)

Under the synchronous output model nothing is ever queued: Node emits
'drain' only after a write() returned false, and these writes never
return false. The process streams therefore describe a stream whose
writes complete synchronously, and the members a flush idiom probes are
constants:

- writableHighWaterMark is 65536 (Node's default for a pipe, a file and
  a TTY);
- writableNeedDrain is false;
- writableLength stays 0.

`on`/`once`/`addListener`/`off`/`removeListener`/`prependListener`/
`prependOnceListener` of "drain" evaluate their receiver and listener and
register nothing: the event cannot fire, and the listener table is not
observable (listenerCount/listeners/eventNames on the process streams
stay refused).

`events.once(process.stdout | process.stderr, "drain")` lowers to
`events.pendingForever`, a fresh promise that never settles, which is
Node's behavior for a stream no write asked to drain; it holds no loop
handle, so a program awaiting it still exits.

The fallback declarations gain writableHighWaterMark, writableNeedDrain
and the event methods, so typed programs can ask.

Corpus: `tests/corpus/process-stream-drain.ts`; the diagnostics fixture
`process-stream-listener-count.ts` pins that reading the listener table
stays refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:37:57 -07:00
co-authored by Claude Opus 5.5
parent cf369f70af
commit bd06b809f1
11 changed files with 200 additions and 4 deletions
@@ -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.
+12
View File
@@ -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;
@@ -381,6 +381,9 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -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
@@ -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 (
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/process-stream-drain.ts"
],
"diags": []
}
@@ -0,0 +1,18 @@
{
"order": [
"<repo>/tests/diagnostics/process-stream-listener-count.ts"
],
"diags": [
{
"code": "SC0001",
"message": "Property 'listenerCount' does not exist on type 'WriteStream'.",
"loc": {
"file": "<repo>/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"
}
]
}
+46
View File
@@ -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<void> {
if (process.stdout.writableLength > 0) {
await new Promise<void>((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);
@@ -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"));
@@ -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
@@ -0,0 +1 @@
{ "plain": 5912, "san": 124235 }