Skip to content

Bound Cmd.Wait so a leaked stdout/stderr pipe can't hang hooks - #4141

Merged
DrJosh9000 merged 5 commits into
mainfrom
fix-hook-wait-hang
Aug 3, 2026
Merged

Bound Cmd.Wait so a leaked stdout/stderr pipe can't hang hooks#4141
DrJosh9000 merged 5 commits into
mainfrom
fix-hook-wait-hang

Conversation

@claude

@claude claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Requested by Dan Niknam, Josh · Slack thread

Description

Fixes an intermittent hang in agent lifecycle hooks (seen in CI as TestAgentShutdownHook/with_registered_agents_env hanging for 10 minutes, then panic: test timed out).

Before: the agent pipes hook output through an io.Pipe, so a hook's Stdout/Stderr is an *io.PipeWriter rather than an *os.File. Because of that, os/exec allocates an internal OS pipe and a background copy goroutine, and Cmd.Wait cannot return until that goroutine sees EOF — which requires every copy of the pipe's write-end fd to be closed. If a hook backgrounds a subprocess (e.g. sleep 30 &), or a concurrently-spawned sibling races on the fd, and that process inherits the write-end, the pipe never EOFs. Cmd.Wait then blocks indefinitely, the deferred pipe-writer close never runs, the output scanner idles, and the agent/test hangs until a 10-minute timeout.

After: the post-exit I/O wait is bounded, so a held-open stdout/stderr pipe can no longer hang Cmd.Wait. A clean process exit whose only remaining wrinkle is the leaked pipe is treated as success and is not misreported as a hook failure.

Context

This is one of a cluster of process/hook flakes that appeared with the Go 1.25 → 1.26 upgrade. The Windows invalid-handle flake and the Linux fork/exec: bad file descriptor flake are a related but distinct spawn/wait race that is not addressed here (they could not be reproduced) and are tracked separately.

Root cause was confirmed from a CI goroutine dump: one goroutine stuck in bufio.Scanner.Scan at clicommand/agent_start.go, another stuck in os/exec.(*Cmd).WaitawaitGoroutines, called from internal/process/process.go.

Changes

Approach chosen: Cmd.WaitDelay on the non-PTY path plus ErrWaitDelay handling (the minimal, lower-risk fix), rather than rewiring the internal/process/internal/shell plumbing to hand the child real os.Pipe() *os.File ends. WaitDelay bounds only the wait after the process has exited, so normal fast or briefly-streaming hooks are unaffected, and it fully fixes the reproduction.

  • internal/process/process.go
    • startWithoutPTY now sets p.command.WaitDelay before Start.
    • The delay is derived from the configured signal grace period plus a 10s buffer (waitDelay() / waitDelayBuffer). This guarantees the agent's own interrupt-then-group-SIGKILL sequence (onContextCancel) always fires before os/exec's WaitDelay-triggered kill of the primary process, so context-cancellation / grace-period behaviour is unchanged; only the post-exit leaked-pipe wait is newly bounded.
    • complete() now inspects the wait result: an *exec.ExitError is handled as before; exec.ErrWaitDelay on an otherwise-clean exit is logged and treated as a successful (exit 0) completion rather than surfaced as a failure.
  • internal/process/process_test.go + internal/process/main_test.go
    • New deterministic regression test TestProcessRunDoesNotHangWhenChildLeaksStdout: drives Process.Run with an *io.PipeWriter stdout and a child that backgrounds a grandchild which inherits stdout and lingers. The test hangs / times out without this fix and returns within a bounded time with it. Skipped on Windows (the fd-inheritance mechanism is POSIX-specific); the leaked grandchild is cleaned up via t.Cleanup.

Testing

  • Tests have run locally (go test). Verified the new test fails (hangs to a 30s bound) on origin/main and passes (returns in ~10s) with the fix. go build ./..., go vet on touched packages, go test ./internal/process/... -count=3, and go test ./clicommand/ -run 'TestAgentShutdownHook|TestAgentStartupHook' -count=5 all pass.
  • Code is formatted (gofmt clean).

Disclosures / Credits

Written with Claude Code: it reproduced the hang, implemented the WaitDelay fix and ErrWaitDelay handling, and added the regression test, under human direction and review.


Generated by Claude Code

When a Process's Stdout/Stderr is an io.Writer that is not an *os.File
(the agent pipes hook output through an io.Pipe), os/exec allocates an
internal OS pipe and a background copy goroutine. Cmd.Wait cannot return
until that goroutine sees EOF, which requires every copy of the pipe
write-end fd to be closed. If a hook backgrounds a child, or a
concurrently-spawned sibling races on the fd, and it inherits the
write-end, the pipe never EOFs and Cmd.Wait blocks forever. In CI this
surfaced as TestAgentShutdownHook hanging for 10 minutes then panicking
with "test timed out".

Set Cmd.WaitDelay on the non-PTY path so the post-exit I/O wait is
bounded. WaitDelay only starts counting after the process exits, so
normal fast or briefly-streaming hooks are unaffected. It is derived
from the signal grace period plus a buffer so the agent's existing
interrupt-then-group-SIGKILL sequence always wins on context
cancellation, leaving cancellation behaviour unchanged. When WaitDelay
elapses after an otherwise-clean exit, Wait returns exec.ErrWaitDelay;
complete() now treats that as a successful exit rather than a hook
failure.

Add a deterministic regression test that drives Process.Run with an
*io.PipeWriter stdout and a child that backgrounds a grandchild which
inherits stdout. The test hangs (times out) without this fix and returns
within the bound with it.
@claude claude Bot added the internal Non-user facing, internal change. label Jul 30, 2026
@claude
claude Bot marked this pull request as ready for review July 30, 2026 06:02
@claude
claude Bot requested review from a team as code owners July 30, 2026 06:02

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hang is bounded, but the clean-exit case is still returned to lifecycle hooks as a failure; I've left one blocking comment on that path.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 9948, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

About buildsworth

Model: gpt-5.6-sol with xhigh thinking.

How to request a review: Comment @buildsworth-bk review on the PR, or request buildsworth-bk as a reviewer.

Risk labels (how buildsworth classifies risk) — buildsworth classifies risk itself from the diff. To let it approve, grant L2 approval by mentioning @buildsworth-bk (see L2 approval grant):

  • L1 — Low risk (dep bumps, docs/copy, lockfiles, small presentational fixes). buildsworth may approve by default.
  • L2 — Standard risk (new UI, additive API fields, refactors). Approved only with an L2 grant; otherwise comment-only.
  • L3 — High risk (auth, migrations, payments, secrets, perf-critical paths). Human review always required.

Comment thread internal/process/process.go
…reported as failures

When a hook exits cleanly but a backgrounded grandchild inherits and holds
open the output pipe's write-end, os/exec returns exec.ErrWaitDelay once
WaitDelay elapses. complete() already treats this as a clean exit and makes
Process.Run return nil, but it still stored the raw sentinel in p.waitResult.
shell.executeCommand returns Process.WaitResult() to lifecycle hooks whenever
Run returned nil, so the sentinel leaked through and a clean hook was still
reported/logged as a failure.

Clear p.waitResult in the ErrWaitDelay branch so WaitResult() agrees with
Run(). Add a shell-level regression test that drives a leaking command through
Command.Run (the path lifecycle hooks use) and asserts the returned error is
nil; it fails without this change and passes with it. waitDelayBuffer becomes
an exported var so the test can shorten the post-exit wait and stay fast;
production behaviour is unchanged.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Author

Good catch — cleared the exec.ErrWaitDelay sentinel on the WaitResult() path in complete() (not just Run), and added a shell-level regression test that fails without the change and passes with it. @buildsworth-bk review


Generated by Claude Code

@buildsworth-bk-app
buildsworth-bk-app Bot dismissed their stale review July 30, 2026 06:23

Previous concerns have been addressed.

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous concern is addressed, and I found no new issues. Since this changes shared subprocess wait behavior, I’m leaving this as a comment for human review rather than approving.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 9958, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

Comment thread internal/process/process.go Outdated
Comment thread internal/process/process.go Outdated

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no new issues after the latest adjustment. This changes shared subprocess wait behavior, so I’m leaving it for human review rather than approving.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 9991, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

Comment thread internal/shell/shell_test.go Outdated
The shell test override was the only cross-package reference to the
exported WaitDelayBuffer. With the default buffer now 1s, shortening it
to 500ms in the test is not worth the exported global, so drop the
override (the test uses the default) and revert WaitDelayBuffer back to
an unexported const waitDelayBuffer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZxHFYHDMB2gJAhnsGBd6x

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no new issues after the latest adjustment. This changes shared subprocess wait behavior, so I’m leaving it for human review rather than approving.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 9998, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

@DrJosh9000
DrJosh9000 merged commit 3ee0b89 into main Aug 3, 2026
4 checks passed
@DrJosh9000
DrJosh9000 deleted the fix-hook-wait-hang branch August 3, 2026 03:38
@zhming0 zhming0 mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Non-user facing, internal change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants