Skip to content

Shell tool never returns when a command leaves a background process holding its stdio #47350

Closed as not planned

Description

@boxabirds

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.
  • vite already 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

  1. 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.
  2. 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 without lsof.
  • 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

  1. 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' });
    
  2. Ask the agent to run it (node scripts/dev.mjs, or an npm run dev wired
    to the same).

  3. node scripts/dev.mjs exits, but the spawned server keeps running and keeps
    the inherited stdout/stderr open.

  4. The tool call never returns. The session shows as queued indefinitely and no
    further requests reach the model provider.

  5. Confirm: ps -o ppid= -p <server pid> reports 1, and lsof -p <server pid>
    shows fd 1/2 as unix sockets.

  6. 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

  1. github-actions commented on Sep 4, 2026

    @github-actions
    Contributor

    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.

  2. Rocklis commented on Sep 4, 2026

    @Rocklis

    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.

  3. Rocklis commented on Sep 4, 2026

    @Rocklis

    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.

  4. jon-hendry commented on Sep 11, 2026

    @jon-hendry

    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 shells disown — 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 for setsid to 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. sleep writes nothing and hangs it just the same:

    sleep 3 &                                exit=5ms  stdout end=3007ms  stderr end=3009ms
    sleep 3 </dev/null >/dev/null &          exit=3ms  stdout end=4ms     stderr end=3005ms
    sleep 3 </dev/null >/dev/null 2>&1 &     exit=4ms  stdout end=4ms     stderr end=4ms
    

    (spawn(cmd, [], {shell:'/bin/bash', stdio:['ignore','pipe','pipe']}), timing exit against end on each stream.) exit is 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:

    setsid bash -c 'python3 -u capture.py > capture.log 2>&1' </dev/null >/dev/null &
    

    That looks fully redirected, and 2>&1 is right there — but it applies to the inner python. The bash -c wrapper kept the tool's stderr, and /proc/<wrapper>/fd/2 was 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 use systemd-run --user --unit=NAME --collect cmd rather than setsid — it detaches without inheriting anything and survives the group kill.

  5. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    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 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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