Skip to content

fix: silence spurious "write close: broken pipe" on stderr after non-follow process logs - #545

Open
emeka wants to merge 1 commit into
F1bonacc1:mainfrom
certifaction:fix/logs-close-broken-pipe
Open

emeka wants to merge 1 commit into
F1bonacc1:mainfrom
certifaction:fix/logs-close-broken-pipe

Conversation

@emeka

@emeka emeka commented Sep 13, 2026

Copy link
Copy Markdown

Transparency note: this PR was drafted with the help of Claude Code. The diagnosis, code, and tests were reviewed and verified against a live process-compose instance before submission.

Summary

process-compose process logs <proc> (no --follow) prints

write close: write unix ->/…/pc.sock: write: broken pipe

on stderr after a successful log tail, and exits 0. The output on stdout is correct, but the noise makes every invocation look like it failed — downstream tools that forward stderr surface it to end users.

Root cause

LogClient.CloseChannel (src/client/logs.go) unconditionally sends a WebSocket CloseNormalClosure frame during teardown. In non-follow mode the server (handleLog in src/api/ws_api.go) hard-closes the underlying conn via defer ws.Close() once the requested tail has streamed, so by the time the CLI reaches its <-done branch and calls CloseChannel the peer conn is already gone. The courtesy write returns EPIPE and gets printed by

fmt.Fprintln(os.Stderr, "write close:", err)

#216 (2024) moved this line from stdout to stderr but did not address the underlying race.

Fix — two layers

  1. Primary. Add `readDone atomic.Bool` set at `readLogs` exit (LIFO defer ordering so it fires before `close(done)`; a `CloseChannel` racing on `<-done` observes the flag). `CloseChannel` checks it and skips the courtesy write when the read side has already observed the peer close.
  2. Defense in depth. If the write is still attempted and fails with a peer-closed error (`syscall.EPIPE`, `io.EOF`, `net.ErrClosed`, or a `*websocket.CloseError`), classify it benign via `isPeerClosedErr` and stay silent. Real errors surface exactly as before.

Errors from the final `ws.Close` are also swallowed — they indicate an already-torn-down conn, not a client fault worth reporting.

Tests

`src/client/logs_test.go`:

  • `TestCloseChannel_NoStderrAfterPeerClose` — end-to-end against a real `httptest` WebSocket server that closes the conn the way `handleLog` does.
  • `TestCloseChannel_SkipsWriteWhenReadClosed` — whitebox: primary `readDone` path.
  • `TestCloseChannel_SwallowsPeerClosedWrite` — whitebox: defense-in-depth path.
  • `TestIsPeerClosedErr` — 9 subtests on the classification helper.

Verified end-to-end

Against a live process-compose instance running via a session manager that shells out to `process-compose process logs`:

  • Before fix: 0 stdout lines, `write close: … broken pipe` on stderr — the race was also truncating the stream to nothing, not just cosmetic.
  • After fix: full log tail on stdout, clean stderr, exit 0.
  • `--follow` + Ctrl-C behavior unchanged (verified: 126 lines streamed, clean exit, clean stderr).

…follow process logs

`process-compose process logs <proc>` (no --follow) prints
`write close: write unix ->/…/pc.sock: write: broken pipe`
on stderr after a successful log tail, and exits 0. The output is
correct but the noise makes every invocation look like a failure —
downstream tools that forward stderr surface it to end users.

Root cause: LogClient.CloseChannel unconditionally sends a WebSocket
CloseNormalClosure frame during teardown. In non-follow mode the
server (handleLog) hard-closes the underlying conn via
`defer ws.Close()` once the requested tail has streamed, so by the
time the CLI reaches its `<-done` branch and calls CloseChannel the
peer conn is already gone. The courtesy write returns EPIPE and gets
printed by

    fmt.Fprintln(os.Stderr, "write close:", err)

PR F1bonacc1#216 (2024) moved this line from stdout to stderr but did not
address the underlying race.

Fix, two layers:

1. Primary — add readDone atomic.Bool set at readLogs exit (LIFO
   defer ordering so it fires before close(done); a CloseChannel
   racing on <-done observes the flag). CloseChannel checks it and
   skips the courtesy write when the read side has already observed
   the peer close.

2. Defense in depth — if the write is still attempted and fails with
   a peer-closed error (syscall.EPIPE, io.EOF, net.ErrClosed, or a
   *websocket.CloseError), classify it benign via isPeerClosedErr and
   stay silent. Real errors surface exactly as before.

Errors from the final ws.Close are also swallowed — they indicate an
already-torn-down conn, not a client fault worth reporting.

Tests (src/client/logs_test.go):

- TestCloseChannel_NoStderrAfterPeerClose — end-to-end against a real
  httptest WebSocket server that closes the conn the way handleLog
  does.
- TestCloseChannel_SkipsWriteWhenReadClosed — whitebox: primary
  readDone path.
- TestCloseChannel_SwallowsPeerClosedWrite — whitebox: defense-in-
  depth path.
- TestIsPeerClosedErr — 9 subtests on the classification helper.

Verified end-to-end against a live session: non-follow `process logs`
now exits with clean stderr; --follow + Ctrl-C behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@emeka

emeka commented Sep 13, 2026

Copy link
Copy Markdown
Author

Note on the SonarCloud Quality Gate failure — the two go:S5332 findings look like pre-existing lines that my diff pushed into the new-code window rather than anything this PR introduced:

  • src/client/logs.go:47url := fmt.Sprintf("ws://%s/...", l.address, ...) — blame → 54273f8, 2026-08-01
  • src/client/logs.go:158url := fmt.Sprintf("http://%s/...", p.address, ...) — blame → 54273f8, 2026-08-01

My changes added imports and the CloseChannel / isPeerClosedErr helper, which shifted the line numbers of these existing statements into the leak period. On the merits they also don't look actionable here — l.address is either the "unix" marker for a unix-socket dial (WSS on a unix socket adds nothing over filesystem perms) or localhost:<port> for a same-user daemon, so WSS/HTTPS wouldn't create a real trust boundary. The rule is already flagged former-hotspot in SonarCloud, suggesting the pattern was reviewed before.

Happy to defer on whether to mark them "Safe" / "Won't Fix" in SonarCloud, but they seem out of scope for this fix.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant