From f24bb523e86f379f3e5a12a40ecd505a0797d269 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:18:15 +0200 Subject: [PATCH] Exit CLI commands quietly when their output is no longer read (#6097) * fix(cli): exit quietly when stdout's reader is gone When the program that launched the CLI closed its end of stdout, the output write failed with EPIPE and nothing handled the error. Node printed an unhandled 'error' event and exited 1, and the desktop app's CLI passthrough could raise Electron's main-process error dialog. runCli now exits with code 0 on EPIPE from stdout. Other stdout errors still surface as before. Closes #5982 * fix(cli): keep running when stdout's reader is gone Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run` writes its ready line and then supervises the daemon child, so exiting there left the child running without its parent. Drop output nobody reads instead, and let every command finish with its own exit code. * fix(cli): stop following when stdout's reader is gone With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach` kept running after the program reading their output closed it, until interrupted. Both now wait through one helper that stops on SIGINT, SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next line of output the same way Ctrl+C does. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/cli/src/commands/agent/attach.ts | 30 ++---------- packages/cli/src/commands/agent/logs.ts | 14 ++---- packages/cli/src/run.test.ts | 42 ++++++++++++++++ packages/cli/src/run.ts | 14 ++++++ packages/cli/src/utils/wait-for-stop.ts | 24 +++++++++ .../43-agent-follow-closed-stdout.test.ts | 49 +++++++++++++++++++ packages/cli/tests/helpers/test-daemon.ts | 6 +++ 7 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/utils/wait-for-stop.ts create mode 100644 packages/cli/tests/43-agent-follow-closed-stdout.test.ts diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 522905c033..a881f2d7f8 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command { .argument("", "Agent ID (or prefix)"); } import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, @@ -157,31 +158,10 @@ export async function runAttachCommand( await unsubscribe.ready; console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`); - // Handle Ctrl+C to detach gracefully - let detached = false; - const detach = () => { - if (detached) return; - detached = true; - - console.log("\n\nDetaching from agent..."); - unsubscribe(); - client - .close() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); - }; - - process.on("SIGINT", detach); - process.on("SIGTERM", detach); - - // Keep the process alive - await new Promise(() => { - // Wait indefinitely until interrupted - }); + await waitForStop(); + console.log("\n\nDetaching from agent..."); + unsubscribe(); + await client.close(); } catch (err) { await client.close().catch(() => {}); const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 6e32690b39..f555e27b80 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import type { CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, @@ -224,16 +225,7 @@ async function runFollowMode( await unsubscribe.ready; console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); - // Wait for interrupt - await new Promise((resolve) => { - const cleanup = () => { - unsubscribe(); - resolve(); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - }); - + await waitForStop(); + unsubscribe(); await client.close(); } diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 66f4c9b613..651719e806 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -1,10 +1,39 @@ +import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createCli } from "./cli"; import { createCliParseArgv } from "./run"; +const runModuleUrl = new URL("./run.ts", import.meta.url).href; + +// Runs the CLI in a child process whose stdout pipe has no reader, as when the +// program that launched it has already closed its end. +async function runCliWithClosedStdout( + argv: string[], +): Promise<{ code: number | null; stderr: string }> { + const script = ` + const { runCli } = await import(${JSON.stringify(runModuleUrl)}); + process.exitCode = await runCli(${JSON.stringify(argv)}); + await new Promise((resolve) => setTimeout(resolve, 100)); + process.stderr.write("still running\\n"); + `; + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: path.dirname(fileURLToPath(import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const code = await new Promise((resolve) => child.on("close", resolve)); + return { code, stderr }; +} + describe("runCli", () => { it("lets plugin update own --version while preserving global output options", async () => { const cli = createCli() @@ -72,6 +101,19 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]); }); + it("finishes the command quietly when stdout is closed before it writes its output", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-")); + + try { + const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]); + + expect(result.stderr).toBe("still running\n"); + expect(result.code).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); + it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const project = path.join(root, "repository"); diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index b95dd9b419..ac67e80745 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -36,7 +36,21 @@ export function createCliParseArgv(input: { return [...nodeArgv, ...cliArgv]; } +// The program that started the CLI can close its end of stdout before the output +// is written, as `paseo ls | head -1` or a launcher that discards output does. +// Drop the output nobody reads and let the command finish, instead of surfacing +// the failed write as an uncaught error. +function ignoreClosedStdout(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export async function runCli(argv: string[], options: RunCliOptions = {}): Promise { + if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) { + process.stdout.on("error", ignoreClosedStdout); + } + const parseArgv = createCliParseArgv({ argv, cwd: options.cwd ?? process.cwd(), diff --git a/packages/cli/src/utils/wait-for-stop.ts b/packages/cli/src/utils/wait-for-stop.ts new file mode 100644 index 0000000000..8430c39726 --- /dev/null +++ b/packages/cli/src/utils/wait-for-stop.ts @@ -0,0 +1,24 @@ +// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent +// attach`) stop on Ctrl+C, a termination signal, or when the program reading +// their output closes its end, as `| head` does. A closed reader shows up as +// EPIPE on the next write to stdout, so the command stops at its next line of +// output. +export function waitForStop(): Promise { + return new Promise((resolve) => { + const stop = () => { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + process.stdout.off("error", stopOnClosedStdout); + resolve(); + }; + const stopOnClosedStdout = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + stop(); + } + }; + + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.stdout.on("error", stopOnClosedStdout); + }); +} diff --git a/packages/cli/tests/43-agent-follow-closed-stdout.test.ts b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts new file mode 100644 index 0000000000..0191867438 --- /dev/null +++ b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env npx tsx + +/** + * Follow commands stop when nobody reads their output + * + * `paseo agent logs --follow` and `paseo agent attach` stream until they are + * stopped. A program that closes its end of their stdout, as `| head` or a + * launcher that discards output does, stops them the same way Ctrl+C does. + */ + +import assert from "node:assert"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Follow Commands With Closed Stdout Tests ===\n"); + +const daemon = await startTestDaemon(); +const host = `127.0.0.1:${daemon.port}`; + +try { + // An agent created without a prompt stays idle, so no provider is called. + const client = await connectToDaemon({ target: { kind: "endpoint", host } }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.workDir, + title: "closed stdout", + }); + await client.close(); + + for (const args of [ + ["agent", "logs", agent.id, "--follow"], + ["agent", "attach", agent.id], + ]) { + const label = `paseo ${args.slice(0, 2).join(" ")}`; + console.log(`Test: ${label} exits when its stdout reader is gone`); + const result = await runPaseoCli(daemon, [...args, "--host", host], { + closedStdout: true, + timeout: 20_000, + }); + + assert.strictEqual(result.stderr, "", `${label} should exit quietly`); + assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`); + console.log(`✓ ${label} exits when its stdout reader is gone\n`); + } +} finally { + await daemon.stop(); +} + +console.log("=== All follow commands with closed stdout tests passed ==="); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index f8be7e6ead..5b5513d1a7 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -352,6 +352,8 @@ export async function runPaseoCli( timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; + /** Close the reading end of the CLI's stdout, as a launcher that discards output does. */ + closedStdout?: boolean; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -380,6 +382,10 @@ export async function runPaseoCli( const stdout = createOutputCapture(); const stderr = createOutputCapture(); + if (options?.closedStdout) { + proc.stdout?.destroy(); + } + proc.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); });