Skip to content

bash tool hangs on backgrounded processes that inherit a descriptor (residual of #22012, still in 1.18.30) #48414

Description

@jon-hendry

Description

The bash tool doesn't resolve until every process holding its stdout/stderr has exited, even when the direct child exited long before. PR #20901 (closeexit, v1.3.14) fixed the exit-code wait, but the output collector still waits for stream EOF. #22012 reported this residual and was closed by the stale bot with nobody having looked. It still reproduces on 1.18.30.

Two things that differ from #22012, both of which make it worse than reported there:

1. The holder doesn't need to produce output. #22012 describes continuously-writing processes (uvicorn, npm run dev). A completely silent holder hangs identically, because the collector waits on EOF, not on data. Demo using the same stdio shape the tool uses:

import { spawn } from "node:child_process"
for (const cmd of ['sleep 3 &', 'sleep 3 </dev/null >/dev/null &', 'sleep 3 </dev/null >/dev/null 2>&1 &']) {
  const t0 = Date.now()
  const p = spawn(cmd, [], { shell: '/bin/bash', stdio: ['ignore', 'pipe', 'pipe'] })
  const mark = {}
  await new Promise((res) => {
    let left = 3
    const done = (k) => { mark[k] = Date.now() - t0; if (--left === 0) res() }
    p.on('exit', () => done('exit'))
    p.stdout.on('end', () => done('out'))
    p.stderr.on('end', () => done('err'))
  })
  console.log(`${cmd.padEnd(40)} exit=${mark.exit}ms out=${mark.out}ms err=${mark.err}ms`)
}
sleep 3 &                                exit=5ms out=3007ms err=3009ms
sleep 3 </dev/null >/dev/null &          exit=3ms out=4ms    err=3005ms
sleep 3 </dev/null >/dev/null 2>&1 &     exit=4ms out=4ms    err=4ms

exit is immediate in all three, so #20901 is working. The stream ends still lag by the full lifetime of the background process, and line 2 shows one un-redirected descriptor is enough — sleep writes nothing at all.

2. With setsid the hang is unbounded. The 2-minute timeout kills the process group; a setsid-ed child isn't in it, so the descriptor is never released and the timeout cannot recover the call. This is not exotic — it's what you get when an agent tries to keep a background job alive past the timeout, which is a reasonable thing to want given there is no background primitive on the tool.

What I actually hit: the agent ran

setsid bash -c 'python3 -u capture.py > capture.log 2>&1' </dev/null >/dev/null & sleep 75; cat capture.log | head -40

The 2>&1 applies to the inner python, not to the bash -c wrapper, so the wrapper kept the tool's stderr. The foreground part finished and produced its output; the call stayed pinned for 81 minutes with no further LLM requests on the session, until I killed the wrapper by hand.

Possible fix: once exit has fired, stop waiting on the pipes and return what was collected, instead of waiting for EOF from processes the tool no longer owns.

Plugins

none

OpenCode version

1.18.30

Steps to reproduce

  1. Have the agent run a command that backgrounds a process without redirecting both descriptors, e.g. setsid bash -c 'sleep 600 > /tmp/x.log 2>&1' </dev/null >/dev/null &
  2. The foreground part completes and its output is produced, but the tool call never returns.
  3. ls -l /proc/<wrapper-pid>/fd/2 shows socket:[N]; the peer of N is open in the opencode process.
  4. kill <wrapper-pid> — the tool call returns immediately.

Without setsid the same shape stalls until the 2-minute timeout instead of hanging permanently.

Operating System

Ubuntu 24.04.4 LTS (kernel 6.17.0-35)

Terminal

Ghostty

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions