Repository navigation
Description
Description
The shell tool appears to treat a command as finished when its stdout/stderr
reach EOF, rather than when the spawned child exits.
Any command that leaves a
process running after the direct child exits therefore hangs the tool forever:
the child has exited, but the descriptors it passed on are still open, so EOF
never arrives.
The agent generated an ordinary Vite + Node dev launcher and then hung itself on it 3x in one
session.
It appears there is no timeout. The last occurrence sat for 1 hour 44 minutes with no
recovery path, and the session could only be freed by finding and killing the
orphaned processes from another terminal. The UI showed the turn as QUEUED
throughout, which I interpreted as "the model is slow" rather than "this tool call will
never return" — I spent a long time investigating my model provider before
finding the real cause.
What the agent ran
It generated this launcher, which spawns two long-lived children and then
exits:
// scripts/dev.mjs
import { spawn } from 'node:child_process';
const run = (cmd, args, opts = {}) =>
spawn(cmd, args, { cwd: root, stdio: 'inherit', shell: false, ...opts });
const server = run('node', ['server/server.js']);
const client = run('npx', ['vite']);
function shutdown() { server.kill(); client.kill(); process.exit(0); }
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);node scripts/dev.mjs exits immediately. Both children keep running.
State after the hang
Both children are alive, reparented to init, and still holding the tool's
descriptors:
pid 47588 node server/server.js PPID 1
fd 1 -> unix socket 0xba9ae00d...
fd 2 -> unix socket 0xadc3d307...
pid 47601 vite PPID 1
fd 1 -> /dev/null
fd 2 -> unix socket 0xadc3d307... <- same socket as pid 47588
Two details that cost me time and may save you some:
- Both processes share the same stderr socket. Killing only one does not
release the tool call; both have to go. vitealready had stdout on/dev/null, and stderr alone was enough to
keep the tool blocked. A partial redirect is not a workaround.
Timeline of the third occurrence
17:02:11 previous turn completes normally
17:02:12 agent runs the dev script; both children orphan to PPID 1
18:46 tool call still waiting
My model provider received 0 requests in that window, so the request was
never sent; the stall is entirely on the tool call and not on generation (for example).
2 things
- Completion appears to be keyed on stream EOF rather than child exit. The direct
child exited immediately at 17:02:12; only the inherited descriptors kept
the stream open. - The wait seems to have no timeout. Even if (1) is intentional, a tool call that can
block indefinitely with no timeout and no cancellation leaves the session
unrecoverable from inside OpenCode.
I'd argue (2) is more serious: what might otherwise be a recoverable annoyance
becomes a dead session.
Suggested fixes
- Resolve the tool call on child exit and treat later stream output as
best-effort; or give the child its own stdio rather than passing a live pipe
through to grandchildren. - Add a timeout, and on firing name the processes still holding the descriptors
so the cause is visible withoutlsof. - Distinguish "waiting on tool output" from "waiting on the model" in the UI.
The current queued display sent me looking in the wrong place for over an
hour.
On the generated code
stdio: 'inherit' is idiomatic and correct for a dev launcher run in a
terminal: it is how to see server logs. I think issue is that a standard npm run dev can wedge the
agent permanently.
Plugins
No response
OpenCode version
1.18.20
Steps to reproduce
-
Create a dev script that spawns a long-lived child with inherited stdio and
then exits:// scripts/dev.mjs import { spawn } from 'node:child_process'; spawn('node', ['server.js'], { stdio: 'inherit' }); -
Ask the agent to run it (
node scripts/dev.mjs, or annpm run devwired
to the same). -
node scripts/dev.mjsexits, but the spawned server keeps running and keeps
the inherited stdout/stderr open. -
The tool call never returns. The session shows as queued indefinitely and no
further requests reach the model provider. -
Confirm:
ps -o ppid= -p <server pid>reports 1, andlsof -p <server pid>
shows fd 1/2 as unix sockets. -
Killing the spawned processes releases the tool call.
Reproduced multiple in one session with a Vite + Node dev launcher. The
minimal script in step 1 is derived from the observed mechanism rather than
separately exercised.
### Screenshot and/or share link
_No response_
### Operating System
macOS 26.4 (build 25E246), Apple silicon
### Terminal
iTerm2 3.6.10
Activity
github-actions commentedon Sep 4, 2026
This issue might be a duplicate of or related to existing issues. Please check:
Your issue provides much more detail about the specific root cause (background processes inheriting stdio descriptors), which is very helpful. The issues may share the same underlying mechanism.
Rocklis commentedon Sep 4, 2026
I can reproduce this on current dev. CrossSpawnSpawner currently resolves handle.exitCode from the child process close event, so a descendant holding inherited stdio delays it. A minimal test with a descendant that lives for 1.5 seconds makes exitCode take the same 1.55 seconds even though the direct child exits immediately. I am working on a small fix to resolve on the direct process exit event while keeping close as a fallback.
Rocklis commentedon Sep 4, 2026
Update: I found #42275 and #44601 already address this root cause, and #44601 also covers the shell stream-drain path beyond the basic exit signal. I will not open another duplicate PR.
jon-hendry commentedon Sep 11, 2026
Confirming this on Linux (Ubuntu 24.04, opencode 1.18.30). Same mechanism, 81 minutes pinned before I killed the holder by hand. I opened #48414 before finding this one and am closing it as a duplicate — four details from it that aren't here yet:
There is a timeout, but it can't reach the holder. The bash tool has a 2-minute default (10 max) and kills the process group with a 3s force-kill. Anything that left the group —
setsid, and on some shellsdisown— isn't in it, so the descriptor is never released and the call is unrecoverable rather than merely slow. That may be why it looked like there was no timeout at all. An agent reaching forsetsidto keep a job alive past the timeout converts a 2-minute stall into a permanent one.The holder doesn't need to produce output. #22012 described continuously-writing processes (uvicorn,
npm run dev), which reads as if quiet processes are fine. They aren't — the collector waits on EOF, not on data.sleepwrites nothing and hangs it just the same:(
spawn(cmd, [], {shell:'/bin/bash', stdio:['ignore','pipe','pipe']}), timingexitagainstendon each stream.)exitis immediate in all three, so PR #20901 is doing its job; the stream ends still lag by the full lifetime of the background process. Line 2 shows one un-redirected descriptor is enough.Redirect scope is a trap for the agent. What hung me was:
That looks fully redirected, and
2>&1is right there — but it applies to the inner python. Thebash -cwrapper kept the tool's stderr, and/proc/<wrapper>/fd/2was a socket whose peer was open in the opencode process. Killing just the wrapper released the call instantly.Workaround, for anyone landing here: redirect both descriptors on the backgrounded command itself (
cmd </dev/null >log 2>&1 &), and for a job that must outlive the call usesystemd-run --user --unit=NAME --collect cmdrather thansetsid— it detaches without inheriting anything and survives the group kill.thdxr commentedon Oct 11, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊