Skip to content

server: PID 1 does not reap orphaned grandchildren, leaving zombies and shells stuck "running" #53564

Open

Description

@wthsama

Summary

When opencode serve runs as container PID 1 (no init / tini), orphaned shell descendants that get reparented to PID 1 are never reaped. They stay as zombies (<defunct>, PPid=1) and the corresponding shell/tool call stays status=running in the UI forever. Only restarting the container clears it.

Environment

  • opencode version: 2.0.24
  • OS: Linux 6.18.18.c1107-trim, x86_64 (Debian 12 container, Docker)
  • Terminal: Unavailable: server mode, no terminal env vars set
  • Shell: Unavailable: $SHELL unset in container
  • Install/channel: release image ghcr.io/anomalyco/opencode:2.0.24, run via Docker Compose; container Entrypoint=[opencode], Cmd=[serve --hostname 0.0.0.0 --port 4096], no init: true
  • Active plugins: none found ("plugins": [])

Reproduction

  1. Run opencode serve as PID 1 in a container without an init process (no init: true). Confirm cat /proc/1/comm shows opencode.
  2. In a session, start a command whose process tree has an intermediate shell, e.g. sh -c 'sleep 300; echo done' (this is how a background shell with a wrapper looks).
  3. Kill the intermediate shell only: kill -9 <pid-of-sh>. The inner sleep is orphaned and reparented to PID 1.
  4. Let the orphaned sleep exit (or kill it as well). It becomes a zombie: ps -o pid,ppid,stat shows Z with PPID 1.
  5. Observe the shell/tool call in the UI stay status=running indefinitely.

Expected Behavior

PID 1 (the opencode process) should reap orphaned children, either by installing a SIGCHLD handler / calling wait(), or by shipping with an init. Exited shells should be marked completed in the UI; no zombies should accumulate.

Actual Behavior

Zombies accumulate under PID 1 and the shell list keeps showing them as running.

Observed before restart (4 zombies, all PPid=1):

PID   PPID  STAT  COMMAND
 815     1   Z    [ssl_client]
2123     1   Z    [sleep]
4712     1   Z    [sleep]
7804     1   Z    [sleep]

After docker restart opencode-v2: zero zombies, and the stale "running" entries in the UI disappear. Reproducible every time the intermediate shell is killed this way; it does not happen for commands that exit normally while their parent is still alive (the parent reaps them).

Additional Context


Captured with the opencode issue-report skill. Diagnostics collected on 2.0.24 in the container described above.

Activity

  1. wthsama commented on Oct 12, 2026

    @wthsama
    Author

    Re-checked on 2.0.27 (latest V2 tag, 2026-10-12): still not addressed.

    • packages/cli/Dockerfile @ v2.0.27 is unchanged from 2.0.24 — apk add --no-cache libgcc libstdc++ ripgrep, then ENTRYPOINT ["opencode"]. No tini, no --init, so the official image still runs opencode as PID 1.
    • No reaper in the runtime path: SIGCHLD has zero matches in the repo, and neither packages/util/src/process.ts nor packages/server/src/process.ts installs a SIGCHLD/wait() reaper or ships an init.
    • No mention in the V2 CHANGELOG (reap / zombie / SIGCHLD / orphan / init → 0 matches).
    • This issue is still open and unassigned; no fix PR is linked.

    Workaround is unchanged: run with an init (init: true in Compose, or tini as the entrypoint). A two-line image fix would be apk add tini + ENTRYPOINT ["tini", "--", "opencode"] in packages/cli/Dockerfile.

  2. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed report. I can confirm the zombie part on 2.0.24, and it still happens on the latest dev (f7d0ce1).

    What I did:

    • Ran opencode serve as PID 1 of a fresh PID namespace, which matches a container without init: true.
    • Started a shell sh -c 'sleep 47; echo done' through the server's shell API.
    • Ran kill -9 on the intermediate sh, so the sleep was reparented to PID 1, then killed the sleep.

    The sleep stays <defunct> with PPID 1 and is never reaped. The release image's Dockerfile still has ENTRYPOINT ["opencode"] with no init in front of it. The script below reproduces this.

    I could not reproduce the shell staying running. In every case the shell was reported as exited (SIGKILL) within about two seconds of killing the intermediate sh. That held when opencode was PID 1, when a bash parent stayed alive, and through the TUI shell tool. Since a zombie has already closed its file descriptors, the stuck status may come from something else, for example a descendant still holding the output open, as discussed around #50424. If you can share the exact command (and whether it ran as a background shell) that left an entry stuck as running, that would help.

    Until this is addressed, init: true / docker run --init remains a working workaround.

    Reproduction script (pid1-zombies.sh)
    #!/usr/bin/env bash
    # Run `opencode serve` as PID 1 of a fresh PID namespace (like a container with no init),
    # start a server-managed shell `sh -c 'sleep N; echo done'`, kill the intermediate sh,
    # then the orphaned sleep, and check for zombies under PID 1 and the shell's status.
    # Usage: sudo ./pid1-zombies.sh            (uses `opencode` from PATH)
    #        OC_DEV=/path/to/checkout sudo ./pid1-zombies.sh   (runs from source)
    set -u
    PORT=4097
    DIR=$(mktemp -d); cd "$DIR"; git init -q .
    if [ -n "${OC_DEV:-}" ]; then
      CMD=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
    else CMD=(opencode); fi
    unshare --pid --fork --mount-proc "${CMD[@]}" serve --hostname 127.0.0.1 --port $PORT >serve.log 2>&1 &
    for i in $(seq 60); do grep -q "server password" serve.log && break; sleep 1; done
    PW=$(sed -n 's/.*server password //p' serve.log); A=(-u "opencode:$PW")
    INIT=$(for p in $(pgrep -f "serve --hostname 127.0.0.1 --port $PORT"); do grep -q "NSpid:.*[[:space:]]1$" /proc/$p/status && echo $p; done | head -1)
    N=(nsenter -t "$INIT" -p -m --)
    echo "PID 1 in namespace: $("${N[@]}" cat /proc/1/cmdline | tr '\0' ' ')"
    ID=$(curl -s "${A[@]}" -X POST localhost:$PORT/api/shell -H 'content-type: application/json' \
      -d '{"command":"sh -c '"'"'sleep 47; echo done'"'"'"}' | grep -o 'sh_[A-Za-z0-9]*' | head -1)
    sleep 2; "${N[@]}" ps -o pid,ppid,stat,args
    SH=$("${N[@]}" pgrep -x -f "sh -c sleep 47; echo done"); echo "kill -9 intermediate sh ($SH)"; "${N[@]}" kill -9 "$SH"
    sleep 2; echo "after killing sh:"; "${N[@]}" ps -o pid,ppid,stat,args
    echo "kill -9 orphaned sleep"; "${N[@]}" pkill -9 -x -f "sleep 47"; sleep 3
    echo "after killing sleep:"; "${N[@]}" ps -o pid,ppid,stat,args
    echo "shell status: $(curl -s "${A[@]}" localhost:$PORT/api/shell/$ID | grep -o '"status":"[a-z]*"')"
    ZOMBIES=$("${N[@]}" ps -o ppid=,stat= | awk '$1==1 && $2 ~ /^Z/' | wc -l); echo "zombies with PPID 1: $ZOMBIES"
    kill "$INIT" 2>/dev/null; rm -rf "$DIR"

    Run it: bash pid1-zombies.sh

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions