Skip to content

Dispose exec children once on every TUI exit path - #846

Open
TheGreatAxios wants to merge 12 commits into
mainfrom
cl-7534-exec-shutdown-orphaned-child-processes-and-double-dispose
Open

Dispose exec children once on every TUI exit path#846
TheGreatAxios wants to merge 12 commits into
mainfrom
cl-7534-exec-shutdown-orphaned-child-processes-and-double-dispose

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Summary

  • TUI and exec teardown share a once-only dispose owner; the toolset is disposed inside that path so live shell-guard children are killed and reaped.
  • Crash, signal, and quit await shutdown. Quit aborts before waiting on a hung session-op tail. Teardown failure on a done session exits 1; signals stay 128+n.
  • Leftover children after the reap window fail dispose. Shutdown errors are logged, not swallowed.

Verification

  • bun run typecheck, bun run build, and bun run test pass
  • bun run check passes

Fixes CL-7534
Changelog: bug

Stop workers first so a hung session-op cannot delay abort and reap. Log shutdown failures at error while still mapping teardown failure to exit 1.
A leftover after the two-second backstop must reject dispose so the exit 1 path can fire. Abort already SIGKILLs the process group at abort start.
@linear-code

linear-code Bot commented Sep 9, 2026

Copy link
Copy Markdown

CL-7534

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

independent critic found no blocking orphans on primary TUI/exec run_shell paths. Once-only dispose holds. Follow-ups already in: quit shuts down before hung tail, reap timeout fails teardown, shutdown errors logged. Remaining should-fix being addressed: subagent dispose currently swallows posixTools.dispose rejection.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

re-critic of origin/main..HEAD including ed15287 found no blocking orphans on primary TUI/exec run_shell paths. Subagent posix dispose rejection is no longer swallowed on ephemeral teardown (disposeSubAgentSession awaits with no catch). Remaining file-for-later: persist close_agent still best-effort-catches dispose, so a persist worker leftover after reap can still look like success at the fleet layer.

A leftover child after posix reap must fail persist close and parent toolset dispose instead of looking like a successful shutdown. A wedged close still times out as shutdown.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Critic · Request changes

TUI/exec teardown is once-only and leftover posix dispose can fail the close path, but live children are still SIGKILLed only after agent.close(), so a wedged close or the 2s crash/signal race orphans detached shell-guard processes.

Findings

  • src/subagent/dispose.ts:113 / src/subagent/run.ts:1074posixTools.dispose() (the process-group kill) runs only after await agent.close() and await streamPromise. closeOnAbort no longer disposes posix tools. boundedClose then times out as success (src/subagent/run.ts:1111, src/subagent/session-store.ts:30). Sequence: persist worker with a live run_shell child; agent.close() hangs (the documented close-ordering wedge); close_agent / cancelAll hits the deadline; session is shutdown; the detached child is still alive. src/subagent/session-store.test.ts:572 locks in hung-close → shutdown success.

  • src/tui/runner/shutdown.ts:44 / src/exec/runner.ts:171 / src/index.ts:123 — crash/signal Promise.race waits 2s for the whole chain (cancelAllcloseAgentdisposeToolset). Reap is the last leg. Sequence: live parent run_shell; SIGTERM while agent.close() takes >2s; awaitActiveDisposeHost logs the deadline and process.exits; detached children survive. The integration fixture uses agent: null, so it never hits this.

  • src/index.ts:129 — the 2s timer is never cleared when dispose wins. The loser rejects later as unhandledRejection (handler no-ops because terminating is already true).

  • src/exec/runner.ts:891 — leftover dispose after a successful exec is logger.debug plus exitCode = 1. No stderr. Operator sees a silent 1; run.json is already done.

  • src/tui/runner/exit.ts:544 — shutdown now precedes awaitTail, but the tail is still unbounded. A hung session-op still prevents run.json, telemetry, and the teardownFailed exit 1. src/tui/runner/exit.test.ts:56 documents the hang.

  • src/tui/runner/shutdown.ts:18completion starts as Promise.resolve(). A signal that re-enters after started = true and before completion = (async () => …)() (window is disposeHost() + starting cancelWorkers) observes a finished dummy and can process.exit before reap.

Notes

  • Once-only plugin dispose, leftover throw from reapLiveChildren, and TUI teardownFailed → exit 1 are fine when close actually returns in time.
  • Permanent tests to keep: hung agent.close() with a live tracked child must kill or fail close, not report shutdown; signal path with slow closeAgent must still reap; successful exec + throwing toolset dispose must be exit 1 and visible.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

re-critic of origin/main..HEAD at 1f6c66c found no remaining blocking leftover-as-success swallow on TUI quit, disposeExecRuntime, persist close_agent, or parent toolset dispose. cancelAll now awaits retained close; leftover posix dispose rejects. Remaining should-fix, not blocking: exec runExec maps that reject to exitCode 1 with status still "done" and a debug-only log (no stderr); createAgentToolset.dispose stops the fleet walk after the first leftover and skips parent posixTools.dispose.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

re-critic of origin/main..HEAD at 7a2ab30 found both remaining should-fixes closed. Leftover exec dispose now fails the ExecResult (exit 1, status failed, error + stderr). Parent toolset dispose finishes remaining closeOne and posix teardown before rethrowing. No blocking leftover-as-success swallow remains on TUI quit, disposeExecRuntime, persist close_agent, or runExec. File-for-later only: on-disk persist("done") / TUI run.json still record a completed send when teardown later fails.

A hung agent.close used to run before process-group reap, so teardown
could report success while detached run_shell children were still live.
Dispose first, fail a close deadline instead of succeeding, and clear
the two-second host timer when dispose wins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant