Skip to content

watcher: detach from controlling terminal to survive terminal close - #90

Merged
alexeyzimarev merged 2 commits into
mainfrom
worktree-toasty-gliding-micali
May 25, 2026
Merged

alexeyzimarev merged 2 commits into
mainfrom
worktree-toasty-gliding-micali

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Summary

  • Watcher inherits the coding agent's controlling terminal and process group. When the user closes the terminal, the kernel delivers SIGHUP to every process in the foreground group — codex/Claude and the watcher — and the watcher dies instantly (no SIGHUP handler, default action = terminate). The 5-second parent-PID poll added in [AI-647] Close orphaned session when watcher's parent coding agent exits #75 never gets to run, so PostSessionEndOnParentExitAsync never fires and the session stays "active" in the UI forever. Reproduced with codex session 019e5e36ce9c7053a8ef8adfb67d7857 — log ends mid-stream at 11:08:08 with no shutdown markers, both watcher (22271) and parent (21667) gone.
  • Fix: call setsid() at watcher startup so the process moves to a new session with no controlling terminal, and SIGHUP from terminal close no longer reaches it. The captured parentPid (coding agent's PGID, recorded before the watcher spawned) is unchanged and still resolves to the agent's PID, so the existing 5s poll fires when the agent dies and the cleanup path runs as designed.
  • Defense-in-depth: also register a SIGHUP handler that mirrors the parent-exit cleanup (set parentExited=1, cancel the CTS, ctx.Cancel = true to suppress the default terminate). Covers the edge case where a shell explicitly forwards SIGHUP to its process-group children before setsid lands, or where setsid fails.
  • Vendor-agnostic: same WatcherManager.SpawnWatcher path runs for Claude and Codex. Claude sessions are usually rescued by Claude's own SessionEnd hook firing independently of the watcher, but the watcher is now a reliable backstop for crashes, IDE detach, or SessionEnd failures.

Test plan

  • dotnet build src/Kapacitor.Cli/Kapacitor.Cli.csproj — clean
  • dotnet publish src/Kapacitor.Cli/Kapacitor.Cli.csproj -c Release — no IL3050/IL2026 AOT warnings
  • ProcessHelpersTests (7/7), WatchCommandTests (1/1), WatcherManagerSpawnArgsTests (4/4) pass
  • Manual: start a fresh codex session under a new terminal, close the terminal, verify watcher log shows either Parent pid X exited; shutting down watcher (within ~5s) or Received SIGHUP; treating as parent-exit (immediate), followed by Parent-exit session-end POST succeeded, and that the session no longer shows "active" in the UI

🤖 Generated with Claude Code

Closing the terminal sent SIGHUP to the entire process group (coding agent
+ watcher). The watcher had no SIGHUP handler so it died instantly with
the default terminate action, before its 5-second parent-PID poll could
fire PostSessionEndOnParentExitAsync — leaving sessions stuck "active" in
the UI forever (e.g. codex session 019e5e36ce9c7053a8ef8adfb67d7857).

Call setsid() at watcher startup to move it into a new session with no
controlling terminal so terminal-close SIGHUP no longer reaches it. The
captured parentPid (coding agent's PGID, recorded before spawn) is
unchanged and still resolves to the agent's actual PID, so the existing
5s poll detects the agent dying and runs the cleanup path.

Also register a SIGHUP handler as defense-in-depth that mirrors the
parent-exit cleanup (sets parentExited=1, cancels the CTS, suppresses
the default terminate action) in case a shell forwards SIGHUP to its
process-group children before setsid lands, or if setsid fails.

Vendor-agnostic: benefits Claude too (Claude's SessionEnd hook is the
primary cleanup path, but the watcher is now a reliable backstop for
crashes, IDE detach, or SessionEnd hook failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Detach watcher from controlling terminal to survive terminal close

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Detach watcher from controlling terminal via setsid() to survive terminal close
• Add SIGHUP signal handler as defense-in-depth for edge cases
• Prevent sessions from staying "active" when terminal closes unexpectedly
• Enable reliable parent-exit detection and session-end cleanup
Diagram
flowchart LR
  A["Terminal Close"] -->|SIGHUP to process group| B["Watcher Process"]
  C["setsid() Call"] -->|Detach from terminal| B
  B -->|No controlling terminal| D["SIGHUP Blocked"]
  E["Parent PID Poll"] -->|Detects agent death| F["Session-End POST"]
  G["SIGHUP Handler"] -->|Defense-in-depth| F
  D --> E

Loading

File Changes

1. src/Kapacitor.Cli/Commands/WatchCommand.cs 🐞 Bug fix +31/-10

Add terminal detachment and SIGHUP signal handling

• Call ProcessHelpers.DetachFromControllingTerminal() at startup to detach from controlling
 terminal
• Add SIGHUP signal handler that treats SIGHUP as parent-exit event and triggers cleanup
• Move parentExited variable declaration before signal handlers for clarity
• Add detailed comments explaining terminal detachment and SIGHUP defense strategy

src/Kapacitor.Cli/Commands/WatchCommand.cs


2. src/Kapacitor.Cli/ProcessHelpers.cs ✨ Enhancement +47/-0

Add setsid() P/Invoke and terminal detachment method

• Add P/Invoke binding for setsid() libc function
• Implement DetachFromControllingTerminal() public method that calls setsid() on Unix
• Return true on success or Windows (no-op), false if setsid() fails
• Add comprehensive XML documentation explaining terminal detachment mechanism and edge cases

src/Kapacitor.Cli/ProcessHelpers.cs


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 25, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Signal registrations not retained ✓ Resolved 🐞 Bug ☼ Reliability
Description
RunWatch drops the IDisposable returned by PosixSignalRegistration.Create for SIGTERM and SIGHUP,
leaving the registrations eligible for GC/finalization and silent unregistration. If that happens,
the watcher can miss SIGTERM/SIGHUP and terminate without running the drain + parent-exit
session-end POST path.
Code

src/Kapacitor.Cli/Commands/WatchCommand.cs[R60-72]

Evidence
Both signal handlers are registered via PosixSignalRegistration.Create(...) but the returned
registration objects aren’t assigned to anything, so nothing roots them for the lifetime of the
watcher. This directly undermines the PR’s new SIGHUP backstop and the existing SIGTERM handling.

src/Kapacitor.Cli/Commands/WatchCommand.cs[55-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PosixSignalRegistration.Create(...)` returns an `IDisposable` registration object. In `RunWatch`, both SIGTERM and SIGHUP registrations are created and immediately discarded, which can allow the registrations to be garbage-collected/finalized and therefore unregistered.

## Issue Context
This PR relies on SIGHUP handling as defense-in-depth when `setsid()` fails or when a shell forwards SIGHUP. If the registration is dropped, that defense can disappear at runtime.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/WatchCommand.cs[55-72]

Suggested implementation direction:
- Assign each registration to a local `using var` (or store them in fields) for the lifetime of `RunWatch`, e.g. `using var sigtermReg = ...; using var sighupReg = ...;`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. SIGTERM default not canceled ✓ Resolved 🐞 Bug ☼ Reliability
Description
The SIGTERM handler cancels the CTS but does not set PosixSignalContext.Cancel=true, so the default
SIGTERM action may still terminate the process before shutdown/drain completes. This can bypass the
cleanup path that posts session-end on parent exit and defeats the watcher’s graceful-shutdown
intent.
Code

src/Kapacitor.Cli/Commands/WatchCommand.cs[60]

Evidence
SIGTERM registration currently ignores the context entirely, while the newly added SIGHUP handler
explicitly sets ctx.Cancel = true, showing the intended suppression of default termination for
graceful cleanup.

src/Kapacitor.Cli/Commands/WatchCommand.cs[60-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The SIGTERM handler ignores the provided `PosixSignalContext`, so the default signal behavior may still run and terminate the process immediately.

## Issue Context
The SIGHUP handler in the same method explicitly sets `ctx.Cancel = true`, indicating the intended pattern to suppress default termination while the watcher runs its shutdown/drain logic.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/WatchCommand.cs[55-61]

Suggested implementation direction:
- Change SIGTERM registration to accept `ctx` and set `ctx.Cancel = true` after calling `cts.Cancel()` (and keep the returned registration rooted as part of the other fix).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Kapacitor.Cli/Commands/WatchCommand.cs Outdated
Comment thread src/Kapacitor.Cli/Commands/WatchCommand.cs Outdated
Address two issues flagged by Qodo's review on #90:

1. PosixSignalRegistration.Create returns an IDisposable that owns the
   handler slot. Both registrations were being discarded, leaving them
   eligible for GC — the finalizer would then silently unregister the
   handler. Bind both to `using var` so they live for the duration of
   RunWatch and are disposed deterministically on exit.

2. For SIGTERM and SIGHUP, .NET runs the signal's default action
   (terminate) after the handler unless ctx.Cancel = true. The
   pre-existing SIGTERM handler called cts.Cancel() and immediately let
   the process die — the main loop never noticed and the final drain
   + session-end POST never ran. SIGHUP already set ctx.Cancel = true;
   make SIGTERM do the same for symmetry and to allow graceful drain
   when systemd/launchd/operators send SIGTERM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit 698f928 into main May 25, 2026
4 checks passed
@alexeyzimarev
alexeyzimarev deleted the worktree-toasty-gliding-micali branch May 25, 2026 11:48
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