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 changes before merge. Reviewed August 29, 2026, 9:59 PM ET / August 30, 2026, 01:59 UTC.

ClawSweeper review

What this changes

The PR adds stdout and stderr error listeners to ACP-created child terminals and tests that pipe-closure errors do not block terminal cleanup.

Merge readiness

⚠️ Ready for maintainer review - 1 item remains

The patch correctly prevents unhandled child-pipe error events from terminating the ACP host, and the supplied compiled before/after trace is sufficient. One minor release-note update remains under repository policy.

Priority: P2
Reviewed head: 45a064f9e2e89ee8080418705b5c87bda90ad9fe

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A focused production-path repair with convincing before/after terminal proof; only the required release-note follow-up remains.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The supplied terminal trace exercises compiled TerminalManager code with a real managed child process: injected EPIPE/EIO on its captured output streams no longer terminate the host, and kill, wait, and release then finish; the before run records the unhandled EPIPE failure.
Patch quality 🐚 platinum hermit (4/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The supplied terminal trace exercises compiled TerminalManager code with a real managed child process: injected EPIPE/EIO on its captured output streams no longer terminate the host, and kill, wait, and release then finish; the before run records the unhandled EPIPE failure.
Evidence reviewed 6 items Current main lacks stream-error listeners: The fetched main revision registers only data listeners on the child stdout and stderr streams, so this behavior is not already implemented on main.
Introduced fix is narrow and on the implicated path: The PR adds listeners directly beside the existing output listeners, preventing error events from being unhandled while preserving exit-driven cleanup.
Regression coverage exercises cleanup after both stream errors: The added test creates a real managed child process, emits EPIPE and EIO on its actual captured streams, then verifies kill, wait, and release complete.
Findings 1 actionable finding [P3] Add the user-facing fix to the changelog
Security None None.

How this fits together

ACP terminal creation starts child commands for an agent session and captures their output streams. Exit state then drives terminal wait, kill, and release operations.

flowchart LR
  A[ACP terminal request] --> B[Terminal creation]
  B --> C[Child process]
  C --> D[Output streams]
  D --> E[Error listener]
  C --> F[Exit status]
  E --> G[Wait and release]
  F --> G
Loading

Before merge

  • Add the user-facing fix to the changelog (P3) - This changes observable ACP terminal recovery behavior, and repository policy groups user-facing changelog updates with their PR. Add a concise Unreleased Fixes entry alongside the adjacent ACP/terminal reliability notes.

Findings

  • [P3] Add the user-facing fix to the changelog — CHANGELOG.md:21-33
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production versus test delta production +10, tests +62 The runtime change is narrowly scoped and includes dedicated regression coverage.

Technical review

Best possible solution:

Keep the direct child-stream listener repair and add a concise Unreleased ACP/terminal fix note before landing.

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

Yes, source-reproducible with high confidence: current main has the unhandled stream path, and the supplied compiled before/after harness exercises the managed child stream and cleanup lifecycle.

Is this the best way to solve the issue?

Yes. Attaching handlers at the child stdout/stderr ownership point is the narrowest way to prevent EventEmitter error termination without changing terminal exit semantics.

Full review comments:

  • [P3] Add the user-facing fix to the changelog — CHANGELOG.md:21-33
    This changes observable ACP terminal recovery behavior, and repository policy groups user-facing changelog updates with their PR. Add a concise Unreleased Fixes entry alongside the adjacent ACP/terminal reliability notes.
    Confidence: 0.91

Overall correctness: patch is correct
Overall confidence: 0.96

AGENTS.md: found and applied where relevant.

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

Labels

Label justifications:

  • P2: This is a bounded ACP terminal reliability fix that can otherwise terminate an active client session.
  • 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 supplied terminal trace exercises compiled TerminalManager code with a real managed child process: injected EPIPE/EIO on its captured output streams no longer terminate the host, and kill, wait, and release then finish; the before run records the unhandled EPIPE failure.
  • proof: sufficient: Contributor real behavior proof is sufficient. The supplied terminal trace exercises compiled TerminalManager code with a real managed child process: injected EPIPE/EIO on its captured output streams no longer terminate the host, and kill, wait, and release then finish; the before run records the unhandled EPIPE failure.

Evidence

Acceptance criteria:

  • [P1] pnpm run check.
  • [P1] pnpm run check:docs.

What I checked:

  • Current main lacks stream-error listeners: The fetched main revision registers only data listeners on the child stdout and stderr streams, so this behavior is not already implemented on main. (src/acp/terminal-manager.ts:246, ef6b81e71756)
  • Introduced fix is narrow and on the implicated path: The PR adds listeners directly beside the existing output listeners, preventing error events from being unhandled while preserving exit-driven cleanup. (src/acp/terminal-manager.ts:256, 45a064f9e2e8)
  • Regression coverage exercises cleanup after both stream errors: The added test creates a real managed child process, emits EPIPE and EIO on its actual captured streams, then verifies kill, wait, and release complete. (test/terminal.test.ts:78, 45a064f9e2e8)
  • Supplied real-behavior evidence: The PR body records a before/after run against compiled TerminalManager code: the unpatched path throws EPIPE, while the patched path remains alive and completes kill, wait, and release. (45a064f9e2e8)
  • Recent terminal lifecycle history: Merged terminal lifecycle work was last substantially maintained in the same module by the process-list timeout fix. (src/acp/terminal-manager.ts, 4d9ea54f1f89)
  • Repository release-note policy: Repository guidance groups changelog updates with user-facing changes, and the Unreleased Fixes section already records adjacent ACP terminal reliability fixes. (CHANGELOG.md:21, 45a064f9e2e8)

Likely related people:

  • SebTardif: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Peter Steinberger: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rank-up moves

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

  • Append one concise ACP/terminal entry under CHANGELOG.md Unreleased Fixes.

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 (2 earlier review cycles)
  • reviewed 2026-08-18T23:20:31.258Z sha 45a064f :: needs maintainer review before merge. :: none
  • reviewed 2026-08-23T17:59:11.252Z 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