From 4131f4dc77e3dba2651c9860d0766d81dca37a2b Mon Sep 17 00:00:00 2001 From: Patrick O'Reilly
Date: Tue, 7 Jul 2026 14:53:27 -0700
Subject: [PATCH] orly-repl: don't abort in-flight entries on stdin EOF; let
exit flush (#542)
With piped stdin (printf ... | orly-repl, the docker repl mode's
shape), readline's close event fires as soon as input is exhausted --
while the first entry is still compiling. quit() ran immediately,
tearing down the WebSocket and scratch dir under the entry, and
process.exit(0) then truncated the pending stderr write, so the
failure was silent: banner, one prompt, exit 0, no output. The
interactive path never hits this because stdin stays open; the smoke
held its pipe open too, which is why CI on #536 was green.
Stdin EOF now chains quit() onto the entry queue (quit after pending
entries drain -- Ctrl-D semantics unchanged), and quit sets
process.exitCode instead of calling process.exit, letting the event
loop drain and output flush once stdin, readline, and the socket are
all closed.
The smoke gains a piped-EOF regression scenario: write an expression,
close stdin immediately, require the result and exit 0. Verified
additionally inside the published docker image against the exact
failing invocation from the docker gate on #539.
---
changelog.d/542-repl-eof-race.md | 1 +
clients/repl/smoke/smoke.mjs | 22 ++++++++++++++++++++++
clients/repl/src/index.ts | 12 +++++++++---
3 files changed, 32 insertions(+), 3 deletions(-)
create mode 100644 changelog.d/542-repl-eof-race.md
diff --git a/changelog.d/542-repl-eof-race.md b/changelog.d/542-repl-eof-race.md
new file mode 100644
index 00000000..851cf491
--- /dev/null
+++ b/changelog.d/542-repl-eof-race.md
@@ -0,0 +1 @@
+- **Fixed**: `orly-repl` with piped stdin (`echo '1 + 1;' | orly-repl ...`, the docker `repl` mode's shape) aborted in-flight entries on EOF and exited 0 with no output — the result never printed and the error text was truncated by `process.exit`. EOF now quits only after pending entries drain, exit lets output flush, and the smoke gains a piped-EOF regression scenario (#542).
diff --git a/clients/repl/smoke/smoke.mjs b/clients/repl/smoke/smoke.mjs
index 632bba7f..ecb58fa2 100644
--- a/clients/repl/smoke/smoke.mjs
+++ b/clients/repl/smoke/smoke.mjs
@@ -100,6 +100,28 @@ try {
const code = await exited;
if (code !== 0) throw new Error(`repl exited ${code}\n--- transcript ---\n${transcript}`);
+ // Piped-EOF regression (#542): the docker/CI shape — all input arrives at
+ // once and stdin closes immediately, while the first entry is still
+ // compiling. The entry must complete, print, and exit 0 anyway.
+ const piped = spawn(process.execPath, ["dist/index.js"], {
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ let pipedOut = "";
+ piped.stdout.on("data", (d) => { pipedOut += d; });
+ piped.stderr.on("data", (d) => { pipedOut += d; });
+ const pipedExit = new Promise((resolve) => piped.on("exit", resolve));
+ piped.stdin.end("40 + 2;\n");
+ const pipedCode = await Promise.race([
+ pipedExit,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`piped-EOF: no exit\n--- transcript ---\n${pipedOut}`)), STEP_TIMEOUT_MS),
+ ),
+ ]);
+ if (pipedCode !== 0 || !pipedOut.includes("42\n")) {
+ piped.kill("SIGKILL");
+ throw new Error(`piped-EOF: exit ${pipedCode}\n--- transcript ---\n${pipedOut}`);
+ }
+
console.log("orly-repl smoke: all checks passed");
process.exit(0);
} catch (e) {
diff --git a/clients/repl/src/index.ts b/clients/repl/src/index.ts
index 1448b860..405f04d3 100644
--- a/clients/repl/src/index.ts
+++ b/clients/repl/src/index.ts
@@ -262,7 +262,10 @@ async function main(): Promise