Skip to content

[AI-2857] Spawn detached children without inheriting the agent's handles - #978

Merged
realtonyyoung merged 8 commits into
mainfrom
fix/windows-hook-handle-inheritance
Sep 21, 2026
Merged

realtonyyoung merged 8 commits into
mainfrom
fix/windows-hook-handle-inheritance

Conversation

@Lougarou

Copy link
Copy Markdown
Contributor

AI-2857 — no GitHub issue; this repo's issues are tracked in Linear only.

What & why

On Windows a child spawned from a coding-agent hook inherits the agent's stdout pipe and holds it for its whole lifetime, so the agent's read of the hook's stdout never reaches EOF and it abandons the hook at its timeout. CreateProcess inherits every handle marked inheritable, and an agent invokes its hook holding more of them than GetStdHandle reports, so clearing those three cannot close it. IProcessStarter gains StartDetached, which on Windows goes through CreateProcess with bInheritHandles: false — the only cut that holds, and one System.Diagnostics.Process offers no way to ask for. Unix keeps the fd >= 3 CLOEXEC sweep, which closes its own differently-shaped leak.

Where to look

ClaudeSessionEndHandoff deliberately keeps the old spawn: it hands its payload to the child through stdin, and on Windows a pipe only reaches a child by being inherited. Closing that one needs the spawn to pass an explicit PROC_THREAD_ATTRIBUTE_HANDLE_LIST naming just that pipe.

Verification

Live Codex (0.154.0) against a pristine origin/main control worktree — same machine, same harness, each hook wrapped in a shim timestamping entry and exit, so hook runtime is separated from what the agent does after it exits.

build hook itself gap to next hook outcome
origin/main 2.85 s, rc=0 ~10 min run killed at 600 s
this branch 2.53 s, rc=0 0.5 s SessionStart, UserPromptSubmit, Stop all Completed; 15.4 s total

A watcher spawned by the patched binary was confirmed alive afterwards, so capture still works rather than the spawn silently failing.

Unit suite: 4273 passed, 3 failed — the three IsExcluded_* symlink tests, A required privilege is not held by the client (Windows symlink privilege), which fail the same way on origin/main.

🤖 Generated with Claude Code

CreateProcess inherits every handle marked inheritable, and an agent invokes
its hook holding more of them than GetStdHandle reports, so clearing the three
std handles left a detached child holding the agent's stdout pipe for its whole
lifetime. Only bInheritHandles: false cuts it, and Process cannot ask for that.
The session-end hand-off keeps the old spawn: it passes its payload through the
child's stdin, and a pipe reaches a child only by being inherited.

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

linear-code Bot commented Sep 16, 2026

Copy link
Copy Markdown

AI-2857

@qodo-code-review

qodo-code-review Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

PR Summary by Qodo

Prevent detached Windows children from inheriting agent handles

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Spawn detached Windows children without inheriting coding-agent hook handles.
• Allow session-end continuations to inherit only their stdin payload pipe.
• Preserve Unix CLOEXEC behavior and test handle and stream ownership.
Diagram

graph TD
  A["Detached Callers"] --> B["IProcessStarter"] --> C{"Platform"}
  C -->|Windows| D["CreateProcessW"] -->|No handles| F["Detached Child"]
  C -->|Unix| E["Process.Start"] -->|CLOEXEC sweep| F
  D -->|Stdin only| G["Stdin Owner"] -->|Payload| F
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Pass payload through a temporary file
  • ➕ Allows every Windows detached spawn to disable handle inheritance completely.
  • ➕ Avoids STARTUPINFOEX handle-list interop.
  • ➖ Persists potentially sensitive session payloads to disk.
  • ➖ Requires secure creation, cleanup, and failure-recovery semantics.
  • ➖ Introduces file lifecycle races between the hook and continuation.
2. Introduce a dedicated launcher helper
  • ➕ Isolates native Windows process creation from the primary CLI.
  • ➕ Could centralize detached-process behavior across future callers.
  • ➖ Adds another executable and packaging surface.
  • ➖ Still requires native handle-control logic when launching the final child.
  • ➖ Complicates diagnostics and deployment for a narrowly scoped behavior.

Recommendation: Keep the PR's direct CreateProcessW implementation. It precisely addresses Windows inheritance semantics, preserves stdin delivery without persisting payloads, and retains the established Unix CLOEXEC strategy; the alternatives add security, packaging, or lifecycle costs without eliminating the core native requirement.

Files changed (13) +884 / -74

Enhancement (1) +18 / -0
IProcessStarter.csAdd detached process-starting contracts +18/-0

Add detached process-starting contracts

• Extends the process abstraction with handle-free detached spawning and detached spawning that returns a writable stdin stream. Both APIs return PIDs rather than exposing Process wrappers.

src/Capacitor.Cli/IProcessStarter.cs

Bug fix (8) +512 / -70
ChildStdinStream.csTie detached stdin closure to Process wrapper disposal +70/-0

Tie detached stdin closure to Process wrapper disposal

• Adds a write-only stream wrapper that owns both the child's stdin pipe and its Process wrapper. Synchronous and asynchronous disposal release resources exactly once without terminating the detached child.

src/Capacitor.Cli/ChildStdinStream.cs

RefreshTokenHandoff.csUse handle-free spawning for token refresh handoffs +1/-7

Use handle-free spawning for token refresh handoffs

• Routes detached token-refresh workers through StartDetached so they cannot retain coding-agent hook pipes. Removes manual standard-stream cleanup.

src/Capacitor.Cli/Commands/RefreshTokenHandoff.cs

SkillsAutoSync.csSpawn automatic skill synchronization without inherited streams +3/-10

Spawn automatic skill synchronization without inherited streams

• Replaces redirected Process spawning and background stream draining with the detached starter API. The worker now starts without inheriting hook handles or requiring parent-side drains.

src/Capacitor.Cli/Commands/SkillsAutoSync.cs

ClaudeSessionEndHandoff.csRestrict session-end inheritance to the stdin payload pipe +16/-14

Restrict session-end inheritance to the stdin payload pipe

• Starts the continuation with only its stdin pipe inherited and writes the session payload through the returned stream. Tracks the PID so a partially initialized child can be terminated before inline fallback.

src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs

ProcessHelpers.WindowsSpawn.csImplement selective Windows handle inheritance +355/-0

Implement selective Windows handle inheritance

• Adds CreateProcessW-based detached spawning with either no inherited handles or a PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing only stdin. Includes command-line quoting, environment construction, native resource cleanup, error propagation, and child termination on post-spawn setup failure.

src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs

ProcessWatcherSpawner.csLaunch watchers through the detached starter +6/-15

Launch watchers through the detached starter

• Uses StartDetached for watcher creation and records the returned PID and start token. Removes redirected-stream ownership from the watcher spawning path.

src/Capacitor.Cli/ProcessWatcherSpawner.cs

SystemProcessStarter.csProvide cross-platform detached process implementations +55/-0

Provide cross-platform detached process implementations

• Dispatches Windows detached starts to the native CreateProcessW helpers while preserving the Unix CLOEXEC sweep and Process.Start behavior. Ensures Process wrappers and stdin ownership are released at the appropriate lifecycle boundary.

src/Capacitor.Cli/SystemProcessStarter.cs

WatcherManager.csDetach background generators without inherited hook handles +6/-24

Detach background generators without inherited hook handles

• Migrates what's-done generation and Copilot finalize draining to StartDetached. Logging and PID reporting now use the detached API's returned process identifier.

src/Capacitor.Cli/WatcherManager.cs

Tests (3) +348 / -0
ChildStdinStreamTests.csTest detached stdin and Process ownership semantics +206/-0

Test detached stdin and Process ownership semantics

• Verifies that closing stdin releases its Process wrapper while leaving the child running. Covers exceptional disposal, asynchronous disposal, and repeated mixed disposal calls.

test/Capacitor.Cli.Tests.Unit/ChildStdinStreamTests.cs

FakeProcessStarter.csSupport detached spawning in process test doubles +11/-0

Support detached spawning in process test doubles

• Implements both new detached-start methods through the existing fake behavior. The stdin variant exposes the stub child's input stream for payload assertions.

test/Capacitor.Cli.Tests.Unit/FakeProcessStarter.cs

ProcessHelpersDetachedStdinTests.csVerify Windows stdin-only handle inheritance +131/-0

Verify Windows stdin-only handle inheritance

• Confirms detached payloads reach child stdin and unrelated inheritable handles do not cross into the child. Exercises the Windows attribute-list implementation against real processes and pipes.

test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs

Documentation (1) +6 / -4
ProcessHelpers.csClarify why Windows detached spawns require CreateProcessW +6/-4

Clarify why Windows detached spawns require CreateProcessW

• Updates handle-inheritance documentation to explain why clearing the three standard handles cannot prevent leaks from additional agent-owned handles. Directs detached Windows callers to the new native spawning path.

src/Capacitor.Cli/ProcessHelpers.cs

@qodo-code-review

qodo-code-review Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (4) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Fallback test leaves child alive ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
FakeProcessStarter.StartDetachedWithStdin reads StandardInput before returning the child PID, so
an access failure prevents TrySpawn from recording which process started. The fallback test
deliberately returns a child without redirected stdin, causing that failure before ownership
transfer and leaving the child alive until the test times out.
Code

test/Capacitor.Cli.Tests.Unit/FakeProcessStarter.cs[R40-41]

+    public (int Pid, Stream StandardInput)? StartDetachedWithStdin(ProcessStartInfo psi) =>
+        Start(psi) is { } child ? (child.Id, child.StandardInput.BaseStream) : null;
Relevance

●●● Strong

Close precedent accepted preventing orphaned children when payload writing or stdin handling fails
after spawn.

PR-#649

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The fake evaluates child.StandardInput.BaseStream before constructing the tuple, while the test's
child explicitly lacks stdin redirection. TrySpawn assigns its local PID only after that tuple
returns, so its catch block cannot kill the already-running child even though the test requires it
to disappear.

test/Capacitor.Cli.Tests.Unit/FakeProcessStarter.cs[35-41]
test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeSessionEndHandoffTests.cs[118-140]
src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs[58-80]

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 detached-stdin fake can start a real child and then throw while obtaining its stdin without either returning its PID or terminating it, breaking the fallback lifecycle test.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/FakeProcessStarter.cs[35-41]
- test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudeSessionEndHandoffTests.cs[118-140]

## Recommended Fix
Make the fake preserve the detached-start ownership contract: if obtaining the returned stdin fails after a child starts, terminate and dispose that child before rethrowing. Alternatively, let the test supply a stream that throws during payload writing so the PID is returned before the simulated post-start failure.

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



Remediation recommended

2. Fallback can kill another process ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
TrySpawn and StartDetachedWindowsWithStdin discard the original process handle and later
reacquire the child solely by PID for failure cleanup. If the detached child exits and Windows
reuses its PID before a payload or stream-construction failure is handled, cleanup can kill an
unrelated process tree.
Code

src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs[R78-79]

+                    using var child = Process.GetProcessById(started);
+                    child.Kill(entireProcessTree: true);
Relevance

●● Moderate

Accepted cleanup precedent exists, but PID-reuse rejection evidence is from a different watchdog
context.

PR-#649
PR-#147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new interface returns only a PID and stream, the Windows implementation closes the authoritative
process handle before failure-prone stream creation, and both cleanup paths resolve a fresh process
from that PID. Existing watcher cleanup explicitly records and checks a start token because the
repository recognizes that recycled PIDs must not be signaled.

src/Capacitor.Cli/IProcessStarter.cs[23-29]
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[145-162]
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[185-189]
src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs[64-80]
src/Capacitor.Cli/WatcherManager.cs[86-102]

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

## Issue description
Detached-stdin failure cleanup discards the original process handle and later resolves the child by its reusable PID, which can target an unrelated process.

## Fix Focus Areas
- src/Capacitor.Cli/IProcessStarter.cs[23-29]
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[145-162]
- src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs[64-80]

## Recommended Fix
Return an owning detached-child abstraction that retains the original process handle alongside stdin. Use that exact handle to terminate the child on payload or stream-construction failure, and dispose it after successful handoff instead of reacquiring a process by PID.

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


3. Hook comments preserve stale run timing 📘 Rule violation ⚙ Maintainability
Description
StartDetachedWindows's remarks cite a Codex measurement and a roughly 33-second timeout on every
turn instead of limiting the explanation to the handle-inheritance invariant. A change to Codex's
timeout or hook behavior leaves this empirical narrative inaccurate even though the implementation
rationale remains valid.
Code

src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[R21-24]

+    /// <c>CreateProcess</c> inherits every one of them. Measured against Codex: a watcher
+    /// spawned from a hook held the agent's stdout pipe open for the watcher's whole
+    /// lifetime, so the agent's read of the hook's stdout never reached EOF and it abandoned
+    /// SessionStart and Stop at its hook timeout, ~33s apiece, on every turn. The std-handle
Relevance

●●● Strong

Recent precedents accept trimming historical, vendor-specific, and time-sensitive narrative
comments.

PR-#736
PR-#533
PR-#638

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2897915 prohibits time-sensitive and external process metadata in changed comments. The added
remarks identify a Codex measurement and record an approximate 33-second timeout occurring on every
turn.

Rule 2897915: Avoid time-sensitive or process-reference metadata in code comments
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[21-24]

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 `StartDetachedWindows` remarks embed a vendor-specific measurement and approximate timeout that can become stale independently of the handle-inheritance invariant.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[21-26]

## Recommended Fix
Remove the Codex run narrative and timing. Retain a concise explanation that inheritable pipe handles can prevent hook output from reaching EOF and that `bInheritHandles: false` prevents this.

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


4. Auto-sync can die on warnings ✓ Resolved 🐞 Bug ☼ Reliability
Description
The Unix StartDetached branch immediately closes the child's redirected stderr pipe, although
skills sync --auto does not replace its console writers until command dispatch. When profile
resolution emits a warning before dispatch, the write reaches a pipe with no reader and can abort
the background synchronization instead of completing it.
Code

src/Capacitor.Cli/SystemProcessStarter.cs[R30-31]

+        if (psi.RedirectStandardOutput) process.StandardOutput.Close();
+        if (psi.RedirectStandardError) process.StandardError.Close();
Relevance

●●● Strong

Recent precedents accept fixes preventing detached processes from writing to closed or undrained
redirected pipes.

PR-#677
PR-#649
PR-#147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
StartDetached closes redirected output immediately on Unix, while Program resolves repository
profiles at line 108 and only silences auto-sync streams at lines 483-490. ResolveForRepo writes a
profile warning to stderr at lines 137-141, and the existing comment at Program.cs lines 485-487
explicitly confirms that writing after the pipe reader closes throws on the dead descriptor.

src/Capacitor.Cli/SystemProcessStarter.cs[23-33]
src/Capacitor.Cli/Program.cs[104-108]
src/Capacitor.Cli/Program.cs[478-491]
src/Capacitor.Cli.Core/Config/AppConfig.cs[137-143]

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

## Issue description
On Unix, `StartDetached` closes the parent ends of redirected output immediately, but `skills sync --auto` can write profile-resolution warnings before replacing stdout and stderr with null writers. Such writes target a dead pipe and can terminate the detached synchronization.

## Fix Focus Areas
- src/Capacitor.Cli/SystemProcessStarter.cs[25-33]
- src/Capacitor.Cli/Program.cs[78-108]
- src/Capacitor.Cli/Program.cs[478-491]

## Recommended Fix
Detect the `skills sync --auto` invocation and install null stdout and stderr writers before configuration and profile resolution can emit output. Keep the later dispatch logic focused on invoking the command, and add a test proving a profile warning cannot write to the detached pipe or prevent synchronization.

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


View medium (1)
5. Windows spawn file breaks type naming 📘 Rule violation ⚙ Maintainability
Description
ProcessHelpers.WindowsSpawn.cs declares ProcessHelpers as its sole top-level primary type, so
the file base name does not exactly match the type name. Future additions can follow either
ProcessHelpers.cs or the suffixed file pattern, leaving ownership of the partial type ambiguous.
Code

src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7]

+static partial class ProcessHelpers {
Relevance

●● Moderate

Naming-rule evidence supports cleanup, but recent rejection of similar type/file ownership refactor
makes team response uncertain.

PR-#817

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 3162234 requires the file base name to exactly match its sole primary top-level type. The added
file is named ProcessHelpers.WindowsSpawn.cs, while line 7 declares the primary type as
ProcessHelpers, and none of the rule's additional-type exceptions applies.

Rule 3162234: One primary type per file, with only narrow documented exceptions
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7-7]

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 new file's base name does not match its sole top-level primary type, contrary to the repository's one-primary-type naming convention.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7-7]
- src/Capacitor.Cli/SystemProcessStarter.cs[19-20]

## Recommended Fix
Move the Windows implementation into a dedicated type such as `WindowsProcessStarter` in `WindowsProcessStarter.cs`, retain the native declarations as its nested implementation details, and update the call and documentation references in `SystemProcessStarter`.

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



Informational

6. Stream tests exceed process limits ⊘ Outdated 📘 Rule violation ▣ Testability ⭐ New
Description
ChildStdinStreamTests starts real ping.exe or /bin/sh children without a
[ParallelLimiter<SubprocessLimit>] annotation. Under default parallel execution, its
process-spawning tests do not draw from the suite's shared subprocess budget and can run alongside
every other process-heavy fixture.
Code

test/Capacitor.Cli.Tests.Unit/ChildStdinStreamTests.cs[10]

+public class ChildStdinStreamTests {
Relevance

● Weak

Recent precedent rejected adding subprocess limiter attributes to real process-spawning test
fixtures.

PR-#920

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 2808212 requires test classes that launch real subprocesses to use the shared
subprocess limiter. The new class starts either ping.exe or /bin/sh but declares no limiting
attribute.

Rule 2808212: Use ParallelLimiter<SubprocessLimit> for test classes running real subprocesses
test/Capacitor.Cli.Tests.Unit/ChildStdinStreamTests.cs[10-18]

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

## Issue description
`ChildStdinStreamTests` launches real child processes without participating in the test suite's shared subprocess limit.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/ChildStdinStreamTests.cs[10-10]

## Recommended Fix
Add `[ParallelLimiter<SubprocessLimit>]` to the test class so every test in the fixture draws from the shared subprocess budget.

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


7. Pipe tests exceed process limits 📘 Rule violation ▣ Testability ⭐ New
Description
ProcessHelpersDetachedStdinTests launches real command-shell children without a
[ParallelLimiter<SubprocessLimit>] annotation. During full-width test execution, both Windows
process tests bypass the suite's shared subprocess budget and can add unbounded concurrent children.
Code

test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[17]

+public class ProcessHelpersDetachedStdinTests {
Relevance

● Weak

Recent precedent rejected shared subprocess limiter annotations for fixtures invoking real Git
subprocesses.

PR-#920

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 2808212 requires process-spawning fixtures to use the shared limiter. The new
fixture has no such annotation and passes ComSpec to the detached Windows process starter.

Rule 2808212: Use ParallelLimiter<SubprocessLimit> for test classes running real subprocesses
test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[17-31]

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

## Issue description
`ProcessHelpersDetachedStdinTests` launches real command-shell processes without participating in the test suite's shared subprocess limit.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[17-17]

## Recommended Fix
Add `[ParallelLimiter<SubprocessLimit>]` to the test class so both Windows-only process tests draw from the shared subprocess budget.

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


8. Pipe tests bypass temp injection 📘 Rule violation ▣ Testability ⭐ New
Description
The_payload_reaches_the_childs_stdin manually constructs new TempDir() instead of using a public
required property annotated with [TempDir]. Future setup added to this test class can consequently
follow a second lifecycle pattern rather than the framework-managed fixture used throughout the
project.
Code

test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[26]

+        using var tmp  = new TempDir();
Relevance

● Weak

Recent precedent rejected replacing manually created TempDir instances with injected fixture
properties.

PR-#976
PR-#972

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 2808173 explicitly prohibits new TempDir() calls in test classes and requires a
public required [TempDir] property. The new test constructs and disposes its own instance locally.

Rule 2808173: Use injected [TempDir] public required property in test classes instead of manual fields
test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[17-27]

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 new test class manually creates a `TempDir`, contrary to the required framework-injected temporary-directory lifecycle for test classes.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/ProcessHelpersDetachedStdinTests.cs[17-27]

## Recommended Fix
Add `[TempDir] public required TempDir Tmp { get; init; }` to the class, remove `using var tmp = new TempDir()`, and construct the sink with `Tmp.PathTo("payload.txt")`.

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


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
Review mode: ⚖️ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 12/18, lines 662/200; both must reach the floor). Router rationale: This push adds substantial, security- and resource-sensitive Windows handle-inheritance logic plus cross-platform process and stream-lifetime paths, creating multiple independent opportunities for subtle defects.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 1ebd14f

Results up to commit ac9bf63 ⚖️ Balanced


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


Remediation recommended
1. Auto-sync can die on warnings ✓ Resolved 🐞 Bug ☼ Reliability
Description
The Unix StartDetached branch immediately closes the child's redirected stderr pipe, although
skills sync --auto does not replace its console writers until command dispatch. When profile
resolution emits a warning before dispatch, the write reaches a pipe with no reader and can abort
the background synchronization instead of completing it.
Code

src/Capacitor.Cli/SystemProcessStarter.cs[R30-31]

+        if (psi.RedirectStandardOutput) process.StandardOutput.Close();
+        if (psi.RedirectStandardError) process.StandardError.Close();
Relevance

●●● Strong

Recent precedents accept fixes preventing detached processes from writing to closed or undrained
redirected pipes.

PR-#677
PR-#649
PR-#147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
StartDetached closes redirected output immediately on Unix, while Program resolves repository
profiles at line 108 and only silences auto-sync streams at lines 483-490. ResolveForRepo writes a
profile warning to stderr at lines 137-141, and the existing comment at Program.cs lines 485-487
explicitly confirms that writing after the pipe reader closes throws on the dead descriptor.

src/Capacitor.Cli/SystemProcessStarter.cs[23-33]
src/Capacitor.Cli/Program.cs[104-108]
src/Capacitor.Cli/Program.cs[478-491]
src/Capacitor.Cli.Core/Config/AppConfig.cs[137-143]

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

## Issue description
On Unix, `StartDetached` closes the parent ends of redirected output immediately, but `skills sync --auto` can write profile-resolution warnings before replacing stdout and stderr with null writers. Such writes target a dead pipe and can terminate the detached synchronization.

## Fix Focus Areas
- src/Capacitor.Cli/SystemProcessStarter.cs[25-33]
- src/Capacitor.Cli/Program.cs[78-108]
- src/Capacitor.Cli/Program.cs[478-491]

## Recommended Fix
Detect the `skills sync --auto` invocation and install null stdout and stderr writers before configuration and profile resolution can emit output. Keep the later dispatch logic focused on invoking the command, and add a test proving a profile warning cannot write to the detached pipe or prevent synchronization.

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


2. Hook comments preserve stale run timing 📘 Rule violation ⚙ Maintainability
Description
StartDetachedWindows's remarks cite a Codex measurement and a roughly 33-second timeout on every
turn instead of limiting the explanation to the handle-inheritance invariant. A change to Codex's
timeout or hook behavior leaves this empirical narrative inaccurate even though the implementation
rationale remains valid.
Code

src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[R21-24]

+    /// <c>CreateProcess</c> inherits every one of them. Measured against Codex: a watcher
+    /// spawned from a hook held the agent's stdout pipe open for the watcher's whole
+    /// lifetime, so the agent's read of the hook's stdout never reached EOF and it abandoned
+    /// SessionStart and Stop at its hook timeout, ~33s apiece, on every turn. The std-handle
Relevance

●●● Strong

Recent precedents accept trimming historical, vendor-specific, and time-sensitive narrative
comments.

PR-#736
PR-#533
PR-#638

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2897915 prohibits time-sensitive and external process metadata in changed comments. The added
remarks identify a Codex measurement and record an approximate 33-second timeout occurring on every
turn.

Rule 2897915: Avoid time-sensitive or process-reference metadata in code comments
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[21-24]

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 `StartDetachedWindows` remarks embed a vendor-specific measurement and approximate timeout that can become stale independently of the handle-inheritance invariant.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[21-26]

## Recommended Fix
Remove the Codex run narrative and timing. Retain a concise explanation that inheritable pipe handles can prevent hook output from reaching EOF and that `bInheritHandles: false` prevents this.

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


3. Windows spawn file breaks type naming 📘 Rule violation ⚙ Maintainability
Description
ProcessHelpers.WindowsSpawn.cs declares ProcessHelpers as its sole top-level primary type, so
the file base name does not exactly match the type name. Future additions can follow either
ProcessHelpers.cs or the suffixed file pattern, leaving ownership of the partial type ambiguous.
Code

src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7]

+static partial class ProcessHelpers {
Relevance

●● Moderate

Naming-rule evidence supports cleanup, but recent rejection of similar type/file ownership refactor
makes team response uncertain.

PR-#817

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 3162234 requires the file base name to exactly match its sole primary top-level type. The added
file is named ProcessHelpers.WindowsSpawn.cs, while line 7 declares the primary type as
ProcessHelpers, and none of the rule's additional-type exceptions applies.

Rule 3162234: One primary type per file, with only narrow documented exceptions
src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7-7]

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 new file's base name does not match its sole top-level primary type, contrary to the repository's one-primary-type naming convention.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7-7]
- src/Capacitor.Cli/SystemProcessStarter.cs[19-20]

## Recommended Fix
Move the Windows implementation into a dedicated type such as `WindowsProcessStarter` in `WindowsProcessStarter.cs`, retain the native declarations as its nested implementation details, and update the call and documentation references in `SystemProcessStarter`.

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


Grey Divider

Qodo Logo

Comment on lines +21 to +24
/// <c>CreateProcess</c> inherits every one of them. Measured against Codex: a watcher
/// spawned from a hook held the agent's stdout pipe open for the watcher's whole
/// lifetime, so the agent's read of the hook's stdout never reached EOF and it abandoned
/// SessionStart and Stop at its hook timeout, ~33s apiece, on every turn. The std-handle

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Hook comments preserve stale run timing 📘 Rule violation ⚙ Maintainability

StartDetachedWindows's remarks cite a Codex measurement and a roughly 33-second timeout on every
turn instead of limiting the explanation to the handle-inheritance invariant. A change to Codex's
timeout or hook behavior leaves this empirical narrative inaccurate even though the implementation
rationale remains valid.
Agent Prompt
## Issue description
The `StartDetachedWindows` remarks embed a vendor-specific measurement and approximate timeout that can become stale independently of the handle-inheritance invariant.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[21-26]

## Recommended Fix
Remove the Codex run narrative and timing. Retain a concise explanation that inheritable pipe handles can prevent hook output from reaching EOF and that `bInheritHandles: false` prevents this.

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


namespace Capacitor.Cli;

static partial class ProcessHelpers {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Windows spawn file breaks type naming 📘 Rule violation ⚙ Maintainability

ProcessHelpers.WindowsSpawn.cs declares ProcessHelpers as its sole top-level primary type, so
the file base name does not exactly match the type name. Future additions can follow either
ProcessHelpers.cs or the suffixed file pattern, leaving ownership of the partial type ambiguous.
Agent Prompt
## Issue description
The new file's base name does not match its sole top-level primary type, contrary to the repository's one-primary-type naming convention.

## Fix Focus Areas
- src/Capacitor.Cli/ProcessHelpers.WindowsSpawn.cs[7-7]
- src/Capacitor.Cli/SystemProcessStarter.cs[19-20]

## Recommended Fix
Move the Windows implementation into a dedicated type such as `WindowsProcessStarter` in `WindowsProcessStarter.cs`, retain the native declarations as its nested implementation details, and update the call and documentation references in `SystemProcessStarter`.

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

Comment thread src/Capacitor.Cli/SystemProcessStarter.cs Outdated
@Lougarou
Lougarou marked this pull request as draft September 17, 2026 13:08
Lougarou and others added 5 commits September 17, 2026 15:15
The payload reaches the continuation over a pipe, which a child can only
receive by inheritance, so this spawn names that one handle in the child's
inherit list instead of refusing all of them. Two smaller cuts ride along:
the Unix detached start disposes its Process wrapper, and a refused
CreateProcessW carries the OS reason out to the caller's log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caller is handed a stream and no other reference, so the stream closes
the Process wrapper on Unix and owns the pipe handle on Windows. Ownership
moves to the SafeFileHandle before the FileStream is built: a throwing
constructor would otherwise leave the handle value with two owners, and the
second close could land on whatever Windows had reused it for. An attribute
list is deleted only once initialization has actually succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pipe close can fail with buffered bytes and a child that already exited,
so the owner is disposed from a finally and a gate keeps either disposal
form from releasing twice. The lifetime test drives the child's real stdin,
because a wrapper built over an unrelated stream leaves Process.Close to
shut stdin itself and the child then races the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real FileStream reports nothing about how it was closed and tolerates
repeated closes, so tests built on one stay green with the release gate
removed. The double counts sync and async disposal and can fail its close,
which is what distinguishes released-once from released-twice and proves the
owner is freed when the pipe is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each disposal form has its own finally, and the sync test leaves the async
one free to drop the owner when an async pipe close throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Lougarou
Lougarou marked this pull request as ready for review September 18, 2026 10:19
@Lougarou

Copy link
Copy Markdown
Contributor Author

@realtonyyoung ready for review — out of draft.

An independent Codex review flow ran this to sign-off over five rounds. It returned clean on the final round; eight findings were raised and fixed along the way, five commits on top of the original ac9bf63b.

What it found, in order

# Finding Fix
1 (P1) ClaudeSessionEndHandoff still inherited the agent's handles — it stayed on the old spawn because its payload goes over the child's stdin, and a pipe reaches a child only by inheritance. The original hook hang survived on that path. PROC_THREAD_ATTRIBUTE_HANDLE_LIST naming just that one pipe handle
2 Unix Process wrapper dropped undisposed in StartDetached using (process)
3 CreateProcessW failures discarded the Win32 reason Win32Exception, read immediately
4 The new Unix StartDetachedWithStdin repeated finding 2's leak ChildStdinStream owns the Process
5 DeleteProcThreadAttributeList could run on a never-initialized buffer guarded by an initialized flag; allocation always freed
6 The raw pipe handle had two owners if the FileStream ctor threw — the second close could land on a reused handle value ownership moves to a named SafeFileHandle first; ctor failure disposes it and terminates the child
7 owner.Dispose() was not in a finally, so a throwing pipe close abandoned the process handle disposal gate + finally in both forms
8 The disposal tests did not constrain the change — built on a real FileStream, they stayed green with the gate removed rebuilt on a CountingStream double that records how it was closed and can fail its close

Findings 4–8 are all defects in fixes for earlier findings, which is the part worth knowing: the first round's fix for the P1 introduced its own leak, and two rounds of tests didn't pin what they claimed to.

Verification

  • Full Capacitor.Cli.Tests.Unit: 4197 passed, 83 skipped, 3 failed — the three IsExcluded_* Windows symlink-privilege tests, which fail identically on origin/main.
  • dotnet publish -c Release (NativeAOT): clean, no IL2026/IL3050.
  • The disposal tests are mutation-checked: removing the gate and each finally in turn fails them, so they constrain the contract rather than decorating it.

Two things to know before you read it

  1. The branch does not build without -p:NoWarn=IDE0200. AgentHookPoster.cs:285 and McpFlowsServer.cs:87 raise error IDE0200: Lambda expression can be removed. These are unchanged from the merge-base parent (confirmed by blob identity), so they pre-date this branch and I left them alone — but CI will be red until they're fixed somewhere.
  2. ClaudeSessionEndHandoff now spawns through a new IProcessStarter.StartDetachedWithStdin. That is the riskiest surface here: hand-rolled CreateProcessW interop with an attribute list, and handle ownership that has to hold on every throw path. Rounds 2 and 3 both found real defects in it. Worth your eyes even though the reviewer signed off.

Comment thread src/Capacitor.Cli/Harness/Claude/ClaudeSessionEndHandoff.cs Outdated
Comment thread test/Capacitor.Cli.Tests.Unit/FakeProcessStarter.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9c0aaf3

@realtonyyoung

Copy link
Copy Markdown
Collaborator

I reviewed the latest diff by inspection. Two production concerns remain: on Unix, skills sync --auto can still emit a profile-resolution warning before its console writers are nulled, after the detached parent closes the redirected pipe; and the detached-stdin failure paths reacquire a child by PID after releasing its original process handle, creating a PID-reuse kill risk. The existing inline review threads describe both, so I am not duplicating them. The fake-starter fallback test also appears to start a child and then throw before returning its PID when stdin is not redirected, leaving cleanup without ownership. I did not build or run tests.

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes for the detached auto-sync stderr race and detached-child PID ownership on failure; the fake-starter fallback test also needs cleanup. Details are in my PR comment and existing inline threads. Builds and tests were not run.

Lougarou and others added 2 commits September 18, 2026 15:13
The hook that spawns it closes the pipe read ends as it exits, so a write
after that lands on a dead fd and can take the sync down. Config and profile
resolution both sit between startup and dispatch, and both can warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pid is reusable the moment the child exits, so failure cleanup that
re-resolved one could kill an unrelated process. The child now carries the
handle it was created with — the process wrapper on Unix, the CreateProcess
handle on Windows — and every kill goes through that. Terminating a child the
starter cannot hand back is the same rule seen from the other side: the caller
never receives it, so nothing else could stop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Lougarou

Copy link
Copy Markdown
Contributor Author

@realtonyyoung all three addressed, pushed as fb9afec8 and 1ebd14f8. Both production concerns were real.

1. Auto-sync dying on warnings — fixed, and you're right that this PR caused it.

The regression is visible in the diff: the old code kept the child's stdout/stderr open and drained them with CopyToAsync(Stream.Null), and its comment said the child only silences its own streams later. This PR replaced that with an immediate close, leaving that startup window uncovered — profile resolution runs at Program.cs:~108, the writers weren't nulled until dispatch at :488.

The child now nulls its writers at startup, before config and profile resolution can emit anything. The dispatch-site silencing is gone rather than duplicated.

2. PID-reuse kill — fixed by removing the pid re-acquisition entirely.

New DetachedChild carries the identity the child was created with: the Process wrapper on Unix, the CreateProcess handle (SafeProcessHandle) on Windows. Terminate() goes through that handle, so neither failure path resolves a pid any more. Windows keeps hProcess open instead of closing it and calling Process.GetProcessById later.

This also let ChildStdinStream be deleted — DetachedChild subsumes it, and the async-disposal path it needed (along with an earlier finding about that path's finally) no longer exists.

3. Fake starter leaving a child alive — already fixed before your review, and it was a real orphan.

Linux caught it: A_child_that_never_receives_the_payload_is_killed_before_the_inline_fallback failed on Linux with pid 1478 still carries identity ... after 10s. It's skipped on Windows, so no local run could have shown it. The underlying defect was mine, not the test's: the starter threw after spawning, so TrySpawn never learned the pid and nothing could clean up. The starter now terminates a child it cannot hand back, and the fake models that same contract.

On Linux

You noted you didn't build or run. I did, in a .NET 10 container, because this PR's Unix paths have two tests that Windows always skips.

  • Full unit suite on Linux: 4256 passed, 3 failed. The three are Sweep_failure_propagates_to_exit_code, Config_dir_is_preserved_when_user_level_steps_fail, Cursor_hooks_write_failure_propagates_to_exit_code. They fail identically on the merge-base parent ac9bf63b, and they pass when the same suite runs as a non-root user. They strip write permission with File.SetUnixFileMode and expect EACCES; root bypasses DAC. Environment, not this branch.
  • Real-binary end-to-end (Capacitor.Cli.Tests.Integration/ClaudeSessionEndHandoffTests, which stalls the server so only a process outliving the hook can post) passes on Linux.

One thing worth flagging since it is not caused by this PR but is close to the edge: that integration test asserts the hook returns in under 1s, against Claude's real 1.5s kill. In a loaded container the baseline ac9bf63b failed 4 of 5 runs at 1.29s, 1.42s, 1.29s and 2.76s; this branch failed 1 of 6, worst 1.47s. So the branch is no slower — but the headroom is thin enough that a slow machine can blow the real 1.5s budget. Might deserve its own issue.

Still open, unchanged

The branch does not build without -p:NoWarn=IDE0200 (AgentHookPoster.cs:285, McpFlowsServer.cs:87). Both files are byte-identical to the merge-base parent, so they pre-date this work and I've left them alone — but CI stays red until they're fixed somewhere. Say the word if you'd rather they ride along here.

@realtonyyoung

Copy link
Copy Markdown
Collaborator

NO FINDINGS on the updated head. Auto-sync now silences output before profile resolution can warn; the detached child retains its original process handle for failure cleanup; and the fake starter cleans up a child it cannot hand back. I reviewed the changed production and test code by inspection and did not build or run tests.

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The detached-child and early-output findings from my previous review are addressed. No further actionable findings by inspection; no build or tests run.

@Lougarou

Copy link
Copy Markdown
Contributor Author

CI is green — and a correction to something I said earlier.

Correction: the IDE0200 build break is not real

In my two earlier comments I said this branch "does not build without -p:NoWarn=IDE0200" and that "CI stays red until they're fixed somewhere". That was wrong, and I'm sorry for the noise — please don't go looking for a build break.

The build passes on Windows, Ubuntu and macOS, and both AOT publish checks pass. The cause is local to my machine: global.json pins SDK 10.0.100 (rollForward: latestFeature) and CI installs that, while I was building on 10.0.101, where that rule's severity differs. Nothing to do in AgentHookPoster.cs or McpFlowsServer.cs.

The failing check was a flake, not this PR

The first run failed one test: TranscriptJournalTests.Gap_note_lands_between_the_last_kept_and_the_first_after_the_loss, on await journal.CompleteAsync() returning false. Re-running the job turned the whole matrix green with no code change.

Why it isn't ours:

  • This PR touches zero files in Capacitor.Cli.Daemon, src or test — only src/Capacitor.Cli and test/Capacitor.Cli.Tests.Unit.
  • That test passes 15/15 locally, three runs in a row.
  • The class is already a known flake target: your 30b414d6 ("De-flake the journal pending-gap and version-probe drain tests (Flaky daemon unit tests: TranscriptJournal pending gap and version-probe pipe drain #970) (De-flake the journal pending-gap and version-probe drain tests #994)") edits this exact file, and this branch is based on 0d540954, which predates it — so CI here still runs the pre-de-flake version of that file.
  • main is tripping timing-sensitive tests too at the moment, a different one most runs: HardCap_after_resolve_sessionStart_emits_empty_once, AConsentLessDaemonSeedsTheFloorThatAdmitsAHostedLaunch, A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_the_version, A_version_at_the_front_survives_a_trailing_flood_on_the_same_stream.

Happy to merge main in so this branch picks up the three de-flake commits, if you'd rather it ran against them — say the word.

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