Skip to content

Missing SIGHUP handler causes orphaned processes to leak ~4.7 GB each after terminal death #14504

@coleleavitt

Description

@coleleavitt

Bug Report

Summary

When a controlling terminal is destroyed (SSH disconnect, tmux pane close, terminal emulator crash), opencode processes become orphaned and continue running indefinitely, each consuming ~4.7 GB of RAM. This is because opencode has no SIGHUP handler, and the TUI event loop blocks on stdin that will never produce input, preventing process.exit() in the finally block of index.ts from ever executing.

Observed Impact

On a system running opencode via oh-my-opencode with tmux, 14 orphaned opencode processes accumulated over time, consuming ~66 GB of RAM total. All had PPID=1 (adopted by init), no controlling terminal, and no alive children.

Root Cause

In packages/opencode/src/index.ts, the main flow is:

try {
  await cli.parse()  // blocks on TUI promise → stdin
} catch (e) { ... } finally {
  process.exit()     // only reached when cli.parse() resolves
}

When the TUI command runs (thread.ts), cli.parse() awaits the TUI promise (line 185), which waits for interactive terminal input. When the terminal dies:

  1. Kernel sends SIGHUP to the process group
  2. No SIGHUP handler exists → signal is ignored by the Bun event loop
  3. stdin is now a dead file descriptor but the TUI event loop doesn't detect this
  4. cli.parse() never resolves → process.exit() never runs
  5. Process continues consuming memory indefinitely as an orphan (PPID=1)

Environment

  • opencode v1.2.10
  • Bun 1.3.6
  • Linux (Arch)
  • oh-my-opencode v3.7.4 (manages tmux sessions)

Reproduction

  1. Start opencode in a tmux pane
  2. Kill the tmux pane or session externally (e.g. tmux kill-pane)
  3. Observe the opencode process continues running with PPID=1
  4. Repeat — each orphaned process retains ~4.7 GB

Proposed Fix

Add signal handlers before the main try block in index.ts:

for (const sig of ["SIGHUP", "SIGTERM", "SIGPIPE"] as const) {
  process.on(sig, () => {
    process.exit(1)
  })
}
  • SIGHUP: Terminal death (SSH disconnect, tmux pane close)
  • SIGTERM: Graceful shutdown requests
  • SIGPIPE: Broken pipe when output destination is gone

This ensures process.exit() is called immediately on terminal death, which triggers the existing subprocess cleanup logic and prevents orphaned processes.

Additional Context

The existing comment on lines 198-200 of index.ts already acknowledges that subprocesses can hang:

Some subprocesses don't react properly to SIGTERM and similar signals. Most notably, some docker-container-based MCP servers don't handle such signals unless run using docker run --init.

The same class of problem affects the parent opencode process itself — it doesn't react to SIGHUP at all.

Investigation Notes

  • Bun Workers are threads (via std.Thread.spawn() in web_worker.zig), not processes — they are not the source of zombie child processes
  • The orphaned processes were full opencode instances that lost their controlling terminal
  • Bun's process reaping (WaiterThread in process.zig) only handles children spawned via Bun.spawn(), not the parent process lifecycle

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions