Skip to content

fix(util): kill spawned process groups when opencode exits - #59411

Merged
rekram1-node merged 1 commit into
devfrom
kill-groups-exit
Oct 26, 2026
Merged

rekram1-node merged 1 commit into
devfrom
kill-groups-exit

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

opencode starts every child (MCP servers, shell tool commands, formatters, rg, git) in its own process group, and only stops those groups from scope finalizers. Several exit paths skip the finalizers:

  • the 10s shutdown deadline calls process.exit(1)
  • a second SIGTERM/SIGINT during shutdown exits right away
  • closing the terminal sends SIGHUP, which nothing handled, so the process just died

Anything still running then gets reparented to PID 1, and MCP servers that ignore stdin EOF (npx wrappers, for example) stay there forever. Part of #50363. This replaces #59398 with something much smaller.

Change

spawn (own group)  → groups.add(pid)
leader exits       → groups.delete(pid)
process "exit"     → SIGKILL every group still in the set
  • cross-spawn-spawner.ts keeps a set of live process groups and registers one synchronous exit hook that SIGKILLs whatever is left. Normal teardown already had its chance to stop things gracefully; the hook can't wait. A group is dropped as soon as its leader exits, so a finished shell command's intentional background job is left alone as before, and a reused PID can't be hit.
  • shutdown-deadline.ts treats SIGHUP like SIGTERM, but only when stdin or stdout is a terminal. nohup and the background service have no terminal, and adding a listener would otherwise override nohup's ignore.
  • Windows: no-op, there are no process groups there.
  • A SIGKILL of opencode itself still can't run any hook. That's the remaining part of the issue.

Things meant to outlive opencode don't go through this spawner, so they need no opt-out. I checked:

  • background service (serve --service): raw node:child_process in packages/client/src/service-contender.ts
  • persistent terminal daemon: raw spawn in packages/core/src/persistent-pty/daemon.ts
  • updater, pager, port owner lookup, standalone --stdio server: go through the spawner but are awaited or tied to the CLI's lifetime, so killing them on exit is right

Verification

serve from this branch with a stdio MCP server that ignores stdin EOF and SIGTERM and has its own child:

path before after
SIGTERM, then a second SIGTERM server + child left with PPID 1 gone
SIGHUP with a terminal left with PPID 1 gone
SIGHUP without a terminal left left (unchanged on purpose)
CLI command that starts the background service, then exits service keeps running service keeps running

Also ran bun typecheck in packages/util and packages/cli, test/shutdown-deadline.test.ts in cli, test/effect/cross-spawn-spawner.test.ts, test/mcp.test.ts and test/process in core, and bun run check from the root.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2">
<style>
.s{animation:s 4s infinite}.l{animation:l 4s infinite}.c{animation:c 4s infinite}
@keyframes s{0%,30%{opacity:1}40%,85%{opacity:.2}95%,100%{opacity:1}}
@keyframes l{0%,32%{opacity:.5}40%,85%{opacity:0}95%,100%{opacity:.5}}
@keyframes c{0%,40%{opacity:1;transform:none}50%,85%{opacity:0;transform:translateY(6px)}95%,100%{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.s,.l,.c{animation:none}}
</style>
<g class="s"><rect x="16" y="20" width="64" height="32" rx="6"/><path d="M32 36h32" opacity=".6"/></g>
<path class="l" d="M80 36h48" stroke-dasharray="4 4"/>
<g class="c"><rect x="128" y="20" width="44" height="32" rx="6"/><rect x="184" y="20" width="44" height="32" rx="6" opacity=".6"/><path d="M172 36h12" opacity=".6"/></g>
</svg>

Added by the opencode bot (AI-generated).

Related: #50363

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I reproduced both exit paths on the base and checked them against this branch:

  • process.exit() with a child that ignores SIGTERM and has a grandchild, spawned through the spawner: on the base the whole group is left with PPID 1; with this change it's killed.
  • Terminal hangup (the shutdown-hang fixture under a pty): on the base the child is orphaned; here the hangup becomes a SIGTERM shutdown and the child is killed. Without a terminal, behaviour is unchanged, as intended.

shutdown-deadline and cross-spawn-spawner tests pass, and packages/util type-checks. No blocking concerns.

Public API: unchanged.

Related: #50363

@rekram1-node
rekram1-node merged commit e2bc79d into dev Oct 26, 2026
22 checks passed
@rekram1-node
rekram1-node deleted the kill-groups-exit branch October 26, 2026 22:12
brentyates-swx pushed a commit to swxtchio/swx-opencode-archive that referenced this pull request Nov 4, 2026
brentyates-swx added a commit to swxtchio/swx-opencode-archive that referenced this pull request Nov 6, 2026
* docs: plan transport and lifecycle backport

Implement the assigned transport and lifecycle behavior in existing fork owners.

- Pick each donor in its own -x commit; omit shell-failure UI and absent CLI/MCP payload.
- Disable Bun’s independent fetch deadline only for long network clients and native provider requests; preserve caller signals, domain deadlines, and the #303 watchdog.
- Track detached POSIX groups only while cleanup is owed, preserving #380 escalation and release of intentional descendants; route terminal SIGHUP through the TUI lifecycle.
- Add real-server and child-process regressions with removal proofs, then record donor coverage and focused checks in CHANGESET.md.

* fix(tui): keep long-pending requests past Bun's fetch deadline (anomalyco#55149)

Give the TUI client timeout: false, share the no-timeout fetch helper across run, mini and the TUI via @opencode/util/no-timeout-fetch, and surface existing-session shell failures with a toast and composer restore.

Closes anomalyco#54974

Co-authored-by: Finn763 <finn763@users.noreply.github.com>
(cherry picked from commit 52a10f5)

* fix(core): disable Bun's fetch idle timeout for provider requests (anomalyco#55007)

(cherry picked from commit 460c626)

* fix(util): kill spawned process groups when opencode exits (anomalyco#59411)

(cherry picked from commit e2bc79d)

* fix(core): type Bun fetch timeout overrides

* test(opencode): verify mini attach timeout wiring

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(core): verify abrupt owner process-group cleanup

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(tui): verify SDK long-call fetch policy

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* fix(tui): preserve nohup SIGHUP behavior

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(core): verify native fetch timeout override

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(core): prove native requests outlive default idle timeout

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(opencode): verify attached runs survive Bun idle timeout

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* docs: record transport and lifecycle backport

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* docs: clarify backport regression ownership

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(opencode): allow Bun 1.4.4 server startup window

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test: shorten idle-timeout acceptance tests

Run the native and attached held-response witnesses in bounded Bun 1.4.4 child processes with BUN_CONFIG_HTTP_IDLE_TIMEOUT=2 so they stay out of the multi-minute range.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(tui): prove SDK long calls survive Bun idle expiry

Exercise SDKProvider's external SDK and SSE paths with a held event stream, a real timeout control, and a completed event after the injected short idle expiry.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(opencode): prove mini attach survives Bun idle expiry

Hold the real mini attach session response in a Bun 1.4.4 child process, observe an unmodified control fetch time out first, then release the expected 404 response.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* test(tui): make SIGHUP fixture reports truthful

Keep the nohup fixture scoped to listener policy and process survival, and add a non-TTY non-nohup default-disposition witness while retaining lifecycle assertions only from the real TUI producer.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* fix(tui): clean Worker-owned process groups on exit

Acknowledge Worker group ownership and release events, kill and await remaining groups before the shutdown reply, and use the recorded PGID ledger as the bounded TUI-owner backstop when shutdown is unconfirmed.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* docs: finalize transport exit review coverage

Record the acknowledged TUI Worker group lifecycle, truthful SIGHUP behavior, and short Bun 1.4.4 behavioral witnesses in the backport ledger.

Claude-Session: c368ed23-1e31-4f2c-b305-b84f21d79cc5

* fix(tui): centralize Worker process-group ownership

* docs: plan transport exit review fixes

1. Make worker and parent ownership ledgers converge on each local acquire, release, and cleanup decision even when acknowledgement is rejected or delayed; validate recorded identities before parent-side signalling.
2. Add rejected/delayed acknowledgement regressions for acquire, release, cleanup announcement, and release/reacquire identity fallback.
3. Harden fixture cleanup and deadlines; add Linux-only guards; correct CHANGESET ownership and SIGHUP claims; remove only test hooks no longer needed.
4. Verify focused changed cases and then each importing test file once under checksum-verified Bun 1.4.4; commit without pushing.

* fix(core): reconcile process-group acknowledgement failures

Reconcile Worker and parent ownership ledgers on local acquire, release, and cleanup facts, independent of rejected or delayed acknowledgements. Validate recorded member identities before signalling and preserve released member identities across reacquisition.

Bound identity-owned process fixture cleanup, gate procfs child fixtures to Linux, retain portable SDK cancellation coverage, and correct the #410 SIGHUP and ownership ledger.

Claude-Session: unknown

* test(opencode): gate shell output on spawn readiness

Wait for the process spawn acknowledgement before the ShellTool first-output deadline and reap the child on readiness failure. Tighten process-group fixture types for package typecheck.

Claude-Session: unknown

* fix(core): restore cross-platform process group cleanup

Claude-Session: unavailable (OpenCode session)

* fix(core): unify process-group ownership decisions

Claude-Session: unavailable (OpenCode session)

* fix(core): publish process-group ownership updates

Claude-Session: unavailable (OpenCode session)

* test(opencode): cover cancellation-overlap ownership

Claude-Session: unavailable (OpenCode session)

* test(core): wait for process-group cleanup

Claude-Session: unavailable (OpenCode session)

* test(opencode): harden process-group test results

Claude-Session: unavailable (OpenCode session)

* test(opencode): validate cancellation result shape

Claude-Session: unavailable (OpenCode session)

* test(opencode): type fixture results as unknown

Claude-Session: unavailable (OpenCode session)

---------

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
Co-authored-by: Finn763 <finn763@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant