Repository navigation
process.stdout / process.stderr writableLength, writableHighWaterMark and writableNeedDrain are undefined, so flush-before-exit helpers wait out their timeout #11418
Description
What happened
On process.stdout and process.stderr, writableLength, writableHighWaterMark and
writableNeedDrain are all undefined. write() returns true and writable is true, as in
Node.
A common CLI helper flushes stdio before process.exit(). It resolves at once when
writableLength === 0, and otherwise waits for 'drain'. Under Perry that check is never true, and
'drain' never fires because nothing was queued. So the helper always waits out its cap. A CLI with a
2 s cap exits after 2006 ms under Perry and after 3 ms under Node.
What you expected
The values Node reports when nothing is queued, whether the fd is a TTY, a pipe or a file:
writableLengthis0.writableHighWaterMarkis a positive number (65536, Node's default high-water mark).writableNeedDrainisfalse.
Minimal reproduction
Gap test (test-files/test_gap_process_stdio_writable_state.ts):
// Gap test: process.stdout / process.stderr expose their Writable buffer state.
// With nothing queued Node reports `writableLength` 0, a positive
// `writableHighWaterMark` and `writableNeedDrain` false, whether the fd is a
// TTY, a pipe or a file. Perry left all three undefined, so the common
// "flush stdio, then process.exit()" helper below never saw an empty buffer
// and always waited out its cap for a 'drain' that never fires.
//
// The concrete high-water mark is not printed: it is Node's configurable
// default (`stream.getDefaultHighWaterMark()`), not a property of the fd.
const streams: Array<[string, NodeJS.WriteStream]> = [
["stdout", process.stdout],
["stderr", process.stderr],
];
for (const [name, s] of streams) {
console.log(name + ".writableLength:", typeof s.writableLength, s.writableLength === 0);
console.log(
name + ".writableHighWaterMark:",
typeof s.writableHighWaterMark,
s.writableHighWaterMark > 0,
);
console.log(name + ".writableNeedDrain:", typeof s.writableNeedDrain, s.writableNeedDrain === false);
}
function drain(stream: NodeJS.WriteStream): Promise<void> {
if (stream.writableLength === 0) return Promise.resolve();
return new Promise((resolve) => stream.once("drain", () => resolve()));
}
let cap: ReturnType<typeof setTimeout> | undefined;
const outcome = await Promise.race([
Promise.all([drain(process.stdout), drain(process.stderr)]).then(() => "drained"),
new Promise<string>((resolve) => {
cap = setTimeout(() => resolve("waited out the cap"), 500);
}),
]);
clearTimeout(cap);
console.log("flush before exit:", outcome);Command you ran:
PERRY_SKIP_BUILD=1 PERRY_BIN=target/perry-dev/perry PERRY_RUNTIME_DIR=target/perry-dev \
./run_parity_tests.sh --filter test_gap_process_stdio_writable_state
# or directly: perry compile t.ts -o t && ./t | catOutput (the harness redirects stdout to a file; Node's output is identical with a pipe or a TTY):
node 26.5.1 perry (main and 0.5.1520)
stdout.writableLength: number true stdout.writableLength: undefined false
stdout.writableHighWaterMark: number true stdout.writableHighWaterMark: undefined false
stdout.writableNeedDrain: boolean true stdout.writableNeedDrain: undefined false
stderr.writableLength: number true stderr.writableLength: undefined false
stderr.writableHighWaterMark: number true stderr.writableHighWaterMark: undefined false
stderr.writableNeedDrain: boolean true stderr.writableNeedDrain: undefined false
flush before exit: drained flush before exit: waited out the cap
Environment
- Perry version: 0.5.1520 (npm
latest), andmainat3f6eb6cebuilt from source
(perry 0.5.1654).os_process_streams.rsis unchanged onmainat2bfa4379. - Host OS: macOS 26.6.2 (25G83), arm64
- Target: native
- Rust toolchain (source build):
nightly-2026-08-20, LLVM 22.1.8 - Installed via: npm (0.5.1520); from source (
main) - Node 26.5.1 (the
.node-versionpin) as the oracle
Diagnostic output
FAIL test_gap_process_stdio_writable_state (output mismatch)
Node.js: stdout.writableLength: number true
Perry: stdout.writableLength: undefined false
Parity Pass: 0 Parity Fail: 1
Anything else
-
Cause.
build_stream_object_with_write
builds stdout/stderr from fixed key lists:- the TTY write stream:
write fd emit on once writable addListener removeListener off removeAllListeners - a pipe or file: the same six keys plus the no-op teardown stubs
Neither shape has the three buffer-state properties.
- the TTY write stream:
-
Suggested fix. The
writestubs write synchronously and always returntrue, so nothing is
ever queued. Adding the three properties to both shapes is enough:writableLength0,
writableHighWaterMark65536,writableNeedDrainfalse. If a pending-write path appears later,
writableLengthshould count its bytes and'drain'should fire, as net: socket.write() returns Node's boolean + 'drain'; bound in-flight writes so bursts can't exhaust the loop #11130 did fornet.Socket. -
Real-world impact. A CLI that exits through this helper (2 s cap) took about 2.1 s per
command when compiled with Perry, against about 110 ms under Node. -
Workaround. Treat
writableLength === undefinedas drained. -
I'd like to take this. I have a fix ready on a branch: the three properties on both stdio shapes,
a runtime unit test, and the gap test above. I can open the PR once you assign it.
Activity
fix(runtime): stdio writable state, ServerResponse/Readable inherits-…
fix(runtime): stdio writable state, ServerResponse/Readable inherits-…