Skip to content

fix(acp): handle terminal stdout and stderr stream errors - #507

Open
SebTardif wants to merge 2 commits into
openclaw:mainfrom
SebTardif:fix/terminal-stdio-error-listeners
Open

fix(acp): handle terminal stdout and stderr stream errors#507
SebTardif wants to merge 2 commits into
openclaw:mainfrom
SebTardif:fix/terminal-stdio-error-listeners

Conversation

@SebTardif

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where users running an ACP session that calls terminal/create would lose the whole client process when the child command died and left a broken stdout or stderr pipe. Node treats an error event on those streams as fatal when no listener is attached, so EPIPE or EIO after the child exits could kill the ACP host instead of letting terminal/wait_for_exit and terminal/release finish.

Why This Change Was Made

terminal/create already attaches data listeners on the child pipes and settles the terminal from the process exit event. It did not attach error listeners. This change ignores pipe-death codes on stdout and stderr (EPIPE, EIO, ECONNRESET, ERR_STREAM_DESTROYED) and never throws or exits the host from those streams. The existing exit handler still records the status, so wait and release can finish.

This is not the CLI process.stdout path from closed #441. That helper throws non-EPIPE and can exit. Child pipes must not. Same-file #501 timed out hung process-list helpers; it did not cover stream error events. The child stdio pattern already used on queue-owner stderr is child.stderr.on("error", () => {}).

The missing listeners date to 1c94396 (2026-02-19, "feat: implement full stable ACP spec coverage"), about 180 days on main.

User Impact

An agent that creates a terminal, then sees the child die with a broken pipe, keeps the ACP session alive. Operators can still wait for exit and release the terminal. The host no longer exits because a leftover child pipe raised EPIPE.

Evidence

terminal output from live node against compiled createTerminal (dist-test/src/acp/terminal-manager.js).

Before, on unpatched src/acp/terminal-manager.ts compiled to dist-test, the same script emits { code: "EPIPE" } on the child stdout and the compiled path throws. Exit code 1:

$ node /tmp/pr-acpx-term-err-proof.mjs
{
  "hostAlive": true,
  "hostPid": 59900,
  "threw": {
    "name": "Error",
    "code": "EPIPE",
    "message": "broken pipe"
  }
}

After, on the patched compiled createTerminal path, the same emit stays in-process. kill / waitForTerminalExit / release finish and the host exits 0:

$ node /tmp/pr-acpx-term-err-proof.mjs
{
  "hostAlive": true,
  "hostPid": 59946,
  "terminalId": "f5f08df5-04d9-4b41-9ced-4397163d7eb0",
  "waitResult": {
    "exitCode": null,
    "signal": "SIGTERM"
  },
  "released": true
}

The Node contract with no listener is the same class. A raw EventEmitter plus emit("error", { code: "EPIPE" }) ends the process with exit 1:

$ node --input-type=module -e 'import { EventEmitter } from "node:events"; const stream = new EventEmitter(); stream.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); console.log("UNEXPECTED still alive");'
Error: broken pipe
    at file:///private/tmp/pr-acpx-term-err/[eval1]:4:36
    ...
  code: 'EPIPE'

Patched bundle contains the listeners:

$ rg -n "onStreamError" dist/live-checkpoint-CdLkrZJ2.js
3408:function onStreamError(error) {
3476:proc.stdout.on("error", onStreamError);
3477:proc.stderr.on("error", onStreamError);

Real behavior proof

  • Behavior or issue addressed: ACP terminal/create attached data listeners on the child stdout and stderr pipes with no error listeners. After the child died, a broken pipe (EPIPE / EIO) was an unhandled EventEmitter error and could kill the ACP process. Wait and release never got a chance to finish.

  • Real environment tested: macOS Darwin 25.6.0 arm64, Node v26.7.0, full clone of openclaw/acpx at /tmp/pr-acpx-term-err, compiled dist-test/src/acp/terminal-manager.js plus the production bundle under dist/.

  • Exact steps or command run after this patch:

    node /tmp/pr-acpx-term-err-proof.mjs

    That script imports compiled TerminalManager, calls createTerminal with node -e "setInterval(() => {}, 1000)", emits { code: "EPIPE" } on the child stdout and { code: "EIO" } on stderr, then killTerminal, waitForTerminalExit, and releaseTerminal.

  • Evidence after fix: terminal output from the patched compiled createTerminal path:

    $ node /tmp/pr-acpx-term-err-proof.mjs
    {
      "hostAlive": true,
      "hostPid": 59946,
      "terminalId": "f5f08df5-04d9-4b41-9ced-4397163d7eb0",
      "waitResult": {
        "exitCode": null,
        "signal": "SIGTERM"
      },
      "released": true
    }

    The same script against unpatched compiled createTerminal printed "code": "EPIPE" and exited 1. A raw EventEmitter with no listener also exited 1 with Error: broken pipe.

  • Observed result after fix: The ACP host process stayed alive (hostAlive: true). Child pipe EPIPE / EIO no longer threw. waitForTerminalExit returned signal: "SIGTERM" and releaseTerminal completed.

  • What was not tested: A live network ACP agent driving terminal/create over stdio to a third-party coding agent. Windows-only pipe teardown. Writing to child stdin (stdio for stdin is ignore today).

ACP terminal/create attached data listeners on the child pipes
with no error listeners. A broken pipe after the child exited
could kill the ACP process. Ignore pipe-death codes on those
streams so wait/release can still finish.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
@SebTardif
SebTardif requested a review from a team as a code owner August 18, 2026 23:11
@clawsweeper

clawsweeper Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

CI type-aware oxlint rejected the emit() argument assertions.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 18, 2026
@clawsweeper

clawsweeper Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 23, 2026, 1:59 PM ET / 17:59 UTC.

ClawSweeper review

What this changes

The PR adds error listeners to ACP-created child-process stdout and stderr streams and tests that pipe-death errors do not prevent terminal cleanup.

Regression provenance

Possible regression — probable (reproduction; reviewed change). No predecessor PR is attributed.

Merge readiness

⚠️ Ready for maintainer review - 1 item remains

This remains a useful, focused ACP reliability fix: current main and v0.13.1 still lack child stdout/stderr error listeners. Manual source review found no actionable patch defect; the locally required structured autoreview must be rerun from a full-history checkout because this review tree has no merge base.

Priority: P2
Reviewed head: 45a064f9e2e89ee8080418705b5c87bda90ad9fe

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) The patch is focused and has strong after-fix runtime proof plus regression coverage.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR body includes compiled before/after runtime output showing injected EPIPE/EIO no longer abort the host and terminal wait/release complete.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR body includes compiled before/after runtime output showing injected EPIPE/EIO no longer abort the host and terminal wait/release complete.
Evidence reviewed 7 items Current main remains affected: Current main subscribes to stdout/stderr data but has no stream error listeners at terminal creation.
Latest release remains affected: Released v0.13.1 has the same data-only stream subscriptions, so this fix is neither released nor redundant.
Narrow implementation: The PR registers one shared listener on both child output streams without changing process exit or release behavior.
Findings None None.
Security None None.

Live Verification

Command: pnpm run dev -- --help

Result: PASS (completed)

pnpm run dev -- --help
runner@runnervm76f27:/tmp/clawsweeper-live-proof-507-FqIz7X/target$ pnpm run dev -- --help

› acpx@0.13.0 dev /tmp/clawsweeper-live-proof-507-FqIz7X/target
› tsx src/cli.ts -- --help

pnpm run dev -- --help
Usage: acpx [options] [command] [prompt...]

Headless CLI client for the Agent Client Protocol

Arguments:
  prompt                                  Prompt text

Options:
  -V, --version                           output the version number
  --agent ‹command›                       Raw ACP agent command (escape hatch)
  --cwd ‹dir›                             Working directory (default: "/tmp/clawsweeper-live-proof-507-FqIz7X/target")
  --auth-policy ‹policy›                  Authentication policy: skip or fail when auth is required
  --approve-all                           Auto-approve all permission requests
  --approve-reads                         Auto-approve read/search requests and prompt for writes
  --deny-all                              Deny all permission requests
  --non-interactive-permissions ‹policy›  When prompting is unavailable: deny or fail
  --permission-policy ‹json-or-file›      Permission policy JSON or path (autoApprove, autoDeny, escalate, defaultAction)
  --policy ‹json-or-file›                 Alias for --permission-policy
  --format ‹fmt›                          Output format: text, json, quiet
  --suppress-reads                        Suppress raw read-file contents in output
  --model ‹id›                            Agent model id
  --allowed-tools ‹list›                  Allowed tool names as a comma-separated list (use "" for no tools)
  --max-turns ‹count›                     Maximum turns for the session
  --system-prompt ‹text›                  Replace the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt)
  --append-system-prompt ‹text›           Append text to the agent system prompt (claude-agent-acp via ACP _meta.systemPrompt.append)
  --prompt-retries ‹count›                Retry failed prompt turns on transient errors (default: 0)
  --json-strict                           Strict JSON mode: requires --format json and suppresses non-JSON stderr output
  --no-fs                                 Do not advertise ACP filesystem capabilities
  --no-terminal                           Do not advertise ACP terminal capability
  --timeout ‹seconds›                     Maximum time to wait for agent response
  --ttl ‹seconds›                         Queue owner idle TTL before shutdown (0 = keep alive forever) (default: 300)
  --mcp-config ‹path›                     Load MCP servers from a JSON config file instead of project/global mcpServers
  --verbose                               Enable verbose debug logs
  -h, --help                              display help for command

Commands:
  pi [options] [prompt...]                Use pi agent
  openclaw [options] [prompt...]          Use openclaw agent
  codex [options] [prompt...]             Use codex agent
  claude [options] [prompt...]            Use claude agent
  gemini [options] [prompt...]            Use gemini agent
  cursor [options] [prompt...]            Use cursor agent
  copilot [options] [prompt...]           Use copilot agent
  droid [options] [prompt...]             Use droid agent
  fast-agent [options] [prompt...]        Use fast-agent agent
  grok-build [options] [prompt...]        Use grok-build agent
  iflow [options] [prompt...]             Use iflow agent
  kilocode [options] [prompt...]          Use kilocode agent
  kimi [options] [prompt...]              Use kimi agent
  kiro [options] [prompt...]              Use kiro agent
  mux [options] [prompt...]               Use mux agent
  opencode [options] [prompt...]          Use opencode agent
  pool [options] [prompt...]              Use pool agent
  qoder [options] [prompt...]             Use qoder agent
  qwen [options] [prompt...]              Use qwen agent
  trae [options] [prompt...]              Use trae age
… output truncated …

Assertions:

  • PASS expect_output: Usage

How this fits together

ACP terminal requests spawn and track child commands. Their output streams feed terminal state, which then enables wait-for-exit and release operations to finish a session.

flowchart LR
  A[ACP terminal create] --> B[Spawned child command]
  B --> C[stdout and stderr pipes]
  C --> D[Stream error listener]
  D --> E[Terminal exit state]
  E --> F[Wait and release operations]
Loading

Before merge

  • Resolve merge risk (P1) - Run the required structured autoreview from a full-history checkout before merge; this grafted review tree has no merge base, so that independent pass could not start.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production versus test growth production +10, tests +62 The small runtime listener change is backed by a focused terminal lifecycle regression test.

Merge-risk options

Maintainer options:

  1. Decide the mitigation before merge
    Preserve the stream listeners and lifecycle regression test so broken child pipes cannot abort the ACP host before wait and release complete.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Technical review

Best possible solution:

Preserve the stream listeners and lifecycle regression test so broken child pipes cannot abort the ACP host before wait and release complete.

Do we have a high-confidence way to reproduce the issue?

Yes. Current-main source has output data listeners but no error listeners, and the contributor supplied a compiled before/after runtime transcript that emits pipe errors then completes kill, wait, and release.

Is this the best way to solve the issue?

Yes. Installing harmless listeners at the child-pipe ownership boundary is the narrowest solution and matches the existing queue-owner pipe-error pattern.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against d4c16ab32154.

Labels

Label justifications:

  • P2: A broken child output pipe can terminate an ACP host, but the repair is contained to terminal process lifecycle handling.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes compiled before/after runtime output showing injected EPIPE/EIO no longer abort the host and terminal wait/release complete.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes compiled before/after runtime output showing injected EPIPE/EIO no longer abort the host and terminal wait/release complete.

Evidence

What I checked:

Likely related people:

  • Peter Steinberger: Authored a prior terminal-manager behavior fix in the same path. (role: terminal-area contributor; confidence: medium; commits: 4aa0cc288f33; files: src/acp/terminal-manager.ts)
  • Vincent Koc: Authored recent security maintenance touching the terminal-manager path. (role: adjacent area contributor; confidence: medium; commits: a67569a073db, 2a8d7147b14c; files: src/acp/terminal-manager.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Run the repository-required autoreview against the actual merge base from a full-history checkout.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (1 earlier review cycle)
  • reviewed 2026-08-18T23:20:31.258Z sha 45a064f :: needs maintainer review before merge. :: none

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant