mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
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:
co-authored by
Claude Opus 5.5
parent
cf369f70af
commit
bd06b809f1
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/process-stream-drain.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user