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 (close → exit, 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
- 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 &
- The foreground part completes and its output is produced, but the tool call never returns.
ls -l /proc/<wrapper-pid>/fd/2 shows socket:[N]; the peer of N is open in the opencode process.
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
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 (
close→exit, 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:exitis 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 —sleepwrites nothing at all.2. With
setsidthe hang is unbounded. The 2-minute timeout kills the process group; asetsid-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
The
2>&1applies to the inner python, not to thebash -cwrapper, 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
exithas 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
setsid bash -c 'sleep 600 > /tmp/x.log 2>&1' </dev/null >/dev/null &ls -l /proc/<wrapper-pid>/fd/2showssocket:[N]; the peer ofNis open in the opencode process.kill <wrapper-pid>— the tool call returns immediately.Without
setsidthe 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