Skip to content

Reap orphaned interactive commands when the console detaches mid-prompt#271

Open
stktung wants to merge 2 commits into
mainfrom
ai-fix-stray-interactive-processes
Open

Reap orphaned interactive commands when the console detaches mid-prompt#271
stktung wants to merge 2 commits into
mainfrom
ai-fix-stray-interactive-processes

Conversation

@stktung

@stktung stktung commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Problem

On Windows 11 (and any platform), kcap setup can leave a stray kcap.exe running indefinitely after the user abandons it partway through — e.g. exits before confirming the visibility step. Confirmed with a live stray kcap setup process whose launching parent had already exited, all threads parked in a blocking console read.

Root cause

setup and other interactive commands drive synchronous Spectre.Console prompts (ConfirmationPrompt, SelectionPrompt, TextPrompt) that do a blocking console read with no timeout or cancellation. Only WatchCommand registers Console.CancelKeyPress + PosixSignalRegistration and a parent-exit watchdog; interactive commands had no interrupt handling.

  • Piped/redirected stdin is already safe: the read throws Failed to read input in non-interactive mode and the process exits.
  • A real TTY that later detaches (terminal closed, launching coding-agent killed, detached pseudo-console) delivers neither EOF nor a signal, so the blocking read never returns and the process is orphaned alive.

Reproducible at any prompt, including the very first ConfirmationPrompt (so it can happen "before step 1"). Affects setup, login, profile, use, import, uninstall.

Fix

Add InteractiveLifetime, a shared safety net installed only for interactive commands (explicit allow-list, so hooks/mcp/watch/daemon are untouched). It mirrors what WatchCommand already does for the watcher:

  1. Console.CancelKeyPress + POSIX SIGTERM/SIGHUP handlers that exit deterministically (130).
  2. A parent-process-liveness watchdog (reusing ProcessHelpers.GetParentPid/IsProcessAlive) that self-terminates once the launching process is gone — the only thing that reaps the detached-TTY case on Windows, which delivers no catchable signal from inside a blocking prompt read.

Redirected-stdin behaviour is unchanged (still fails fast via the existing thrown exception).

Testing

  • dotnet build clean.
  • New InteractiveLifetimeTests cover the interactive-command allow-list (prompting commands guarded; watch/daemon/hook/mcp/non-prompting commands not). All unit tests pass.
  • ⚠️ Could not run dotnet publish -c Release to check IL2026/IL3050 AOT warnings — the native toolchain (vswhere/MSVC linker) isn't available on this dev box. The new code uses only patterns already present and AOT-safe in WatchCommand/ProcessHelpers (PosixSignalRegistration, Thread, Environment.Exit, Interlocked), so no new AOT warnings are expected, but a publish check should be run in CI.

Notes

Filing an accompanying GitHub issue was blocked in this environment; the problem statement above stands in for it. This same "no interrupt handling" fragility also explains stuck auth-retry hook processes seen elsewhere — a candidate follow-up to extend the same guard to relevant hook paths.

Interactive commands (setup, login, profile, use, import, uninstall) block
on synchronous Spectre.Console prompts with no timeout or cancellation. Piped
stdin is safe (the read throws and exits), but a real TTY that later detaches
— terminal closed, launching coding-agent killed, detached pseudo-console —
delivers neither EOF nor a signal, so the blocking read never returns and the
process is orphaned alive. This is the reported stray kcap.exe left behind
after `kcap setup` is exited partway through.

Add InteractiveLifetime: a signal + parent-liveness safety net installed for
interactive commands, mirroring what WatchCommand already does for the watcher:
- Console.CancelKeyPress + POSIX SIGTERM/SIGHUP handlers that exit(130).
- A parent-process-liveness watchdog that self-terminates once the launching
  process is gone (the only thing that reaps the detached-TTY case, which
  delivers no signal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@alexeyzimarev

Copy link
Copy Markdown
Member

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Watchdog install race ✓ Resolved 🐞 Bug ☼ Reliability
Description
InteractiveLifetime.Install runs only after ResolveServerUrl/update-check work, and
StartParentWatchdog refuses to arm when the parent PID is already dead; if the parent exits during
that startup window, the watchdog never starts and a later prompt can still block forever. This
undermines the PR’s goal for the “parent died before first prompt” case.
Code

src/Capacitor.Cli/InteractiveLifetime.cs[R100-112]

+        try {
+            StartParentWatchdog(ProcessHelpers.GetParentPid());
+        } catch {
+            // best effort
+        }
+    }
+
+    static void StartParentWatchdog(int? parentPid) {
+        // pid <= 1 means no real parent (init / detached) — nothing sane to
+        // monitor, so skip rather than risk self-terminating immediately.
+        if (parentPid is not { } ppid || ppid <= 1 || !ProcessHelpers.IsProcessAlive(ppid)) {
+            return;
+        }
Evidence
Program.cs performs server URL resolution and starts update-check work before installing
InteractiveLifetime, and InteractiveLifetime skips arming the watchdog entirely when the parent PID
is already dead at install time.

src/Capacitor.Cli/Program.cs[45-77]
src/Capacitor.Cli/InteractiveLifetime.cs[95-112]

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

## Issue description
`InteractiveLifetime.Install()` is invoked after potentially slow startup work (`ResolveServerUrl`, update-check task setup). If the launching parent exits during that gap, `StartParentWatchdog` sees the parent PID as already-dead and returns, leaving no parent-exit protection before the first interactive prompt.

## Issue Context
The watchdog intentionally doesn’t start when `IsProcessAlive(ppid)` is false at install time. Combined with the current call site in `Program.cs`, this creates a race window where the process can become orphaned *before* the interactive guard is armed.

## Fix Focus Areas
- src/Capacitor.Cli/Program.cs[18-77]
- src/Capacitor.Cli/InteractiveLifetime.cs[95-112]

## Suggested fix
Move the `InteractiveLifetime.Install()` call earlier in `Program.cs` (immediately after determining `command`, and after any early-return cases that must avoid installing it), so the parent watchdog is armed before `ResolveServerUrl` / update-check work. Keep the allow-list gate (`IsInteractiveCommand`) the same.

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



Informational

2. PID reuse miss 🐞 Bug ☼ Reliability
Description
The parent watchdog uses periodic IsProcessAlive(ppid) checks on a numeric PID; if the OS reuses
that PID between polls, the watchdog can treat an unrelated process as the parent and fail to exit.
This is an edge case, but it’s a known limitation of PID-only polling.
Code

src/Capacitor.Cli/InteractiveLifetime.cs[R114-121]

+        var thread = new Thread(() => {
+            while (true) {
+                Thread.Sleep(ParentPollInterval);
+
+                if (!ProcessHelpers.IsProcessAlive(ppid)) {
+                    Environment.Exit(InterruptExitCode);
+                }
+            }
Evidence
InteractiveLifetime polls a parent PID indefinitely, and the IsProcessAlive helper documents PID
reuse as a potential hazard for polling windows.

src/Capacitor.Cli/InteractiveLifetime.cs[114-121]
src/Capacitor.Cli/ProcessHelpers.cs[628-637]

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 watchdog checks liveness by PID. PID reuse can cause a false “alive” result after the real parent exits, preventing termination.

## Issue Context
`ProcessHelpers.IsProcessAlive` explicitly notes PID reuse as a theoretical hazard for polling-based approaches.

## Fix Focus Areas
- src/Capacitor.Cli/InteractiveLifetime.cs[107-125]
- src/Capacitor.Cli/ProcessHelpers.cs[628-665]

## Suggested fix
On Windows, open a handle to the parent process once at install time (best-effort) and monitor it (e.g., `WaitForSingleObject` with a timeout loop) instead of repeatedly probing by PID. If opening the handle fails, fall back to the current PID polling behavior.

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


Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli/InteractiveLifetime.cs
Comment on lines +114 to +121
var thread = new Thread(() => {
while (true) {
Thread.Sleep(ParentPollInterval);

if (!ProcessHelpers.IsProcessAlive(ppid)) {
Environment.Exit(InterruptExitCode);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

2. Pid reuse miss 🐞 Bug ☼ Reliability

The parent watchdog uses periodic IsProcessAlive(ppid) checks on a numeric PID; if the OS reuses
that PID between polls, the watchdog can treat an unrelated process as the parent and fail to exit.
This is an edge case, but it’s a known limitation of PID-only polling.
Agent Prompt
## Issue description
The watchdog checks liveness by PID. PID reuse can cause a false “alive” result after the real parent exits, preventing termination.

## Issue Context
`ProcessHelpers.IsProcessAlive` explicitly notes PID reuse as a theoretical hazard for polling-based approaches.

## Fix Focus Areas
- src/Capacitor.Cli/InteractiveLifetime.cs[107-125]
- src/Capacitor.Cli/ProcessHelpers.cs[628-665]

## Suggested fix
On Windows, open a handle to the parent process once at install time (best-effort) and monitor it (e.g., `WaitForSingleObject` with a timeout loop) instead of repeatedly probing by PID. If opening the handle fails, fall back to the current PID polling behavior.

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

Move InteractiveLifetime.Install() ahead of ResolveServerUrl/update-check
so the parent-liveness watchdog is armed before any multi-second startup
work. StartParentWatchdog refuses to arm once the parent is already gone,
so installing after that work left a window where a launching agent could
exit during ResolveServerUrl and leave the first prompt able to orphan the
process — the exact 'parent died before first prompt' case this PR targets.

Addresses qodo review finding on #271.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants