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 { } catch { client.close(); } - process.exit(0); + // No process.exit(): it truncates pending pipe writes (a piped consumer + // would lose the last result/error, #542). With stdin, readline, and the + // socket all closed, the event loop drains and node exits on its own. + process.exitCode = 0; }; const runCommand = async (line: string): Promise => { @@ -326,8 +329,11 @@ async function main(): Promise { prompt(); }); + // stdin EOF (piped input, Ctrl-D). Quitting immediately would tear down + // the socket and scratch dir under whatever entry is still compiling + // (#542) — chain it so EOF means "quit after pending entries drain". rl.on("close", () => { - void quit(); + chain = chain.then(() => quit()); }); prompt(); @@ -335,5 +341,5 @@ async function main(): Promise { main().catch((e) => { console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); + process.exitCode = 1; });