Skip to content

Fix 14 process lifecycle bugs: orphans, races, leaks, cancellation#25

Merged
Mpdreamz merged 2 commits intomasterfrom
fix/process-lifecycle-bugs
Mar 30, 2026
Merged

Fix 14 process lifecycle bugs: orphans, races, leaks, cancellation#25
Mpdreamz merged 2 commits intomasterfrom
fix/process-lifecycle-bugs

Conversation

@Mpdreamz
Copy link
Copy Markdown
Contributor

Summary

Full review of all public Proc methods surfaced 14 bugs ranging from a critical infinite recursion to medium thread-safety gaps. This PR fixes all of them and adds a regression test per source file.

Critical / High

  • StartRedirected(IConsoleLineHandler, string, params string[]) — fixed infinite recursion (params string[] coercion made it call itself, causing StackOverflowException)
  • ExecAsync — replaced synchronous WaitForExit(int) (which blocked the thread and ignored CancellationToken) with a linked CancellationTokenSource; process is now killed on both timeout and caller cancellation (previously became an orphan); removed HardWaitForExitAsync which unnecessarily wrapped an already-async method in Task.Run
  • Exec — process is now killed before HardWaitForExit on timeout (previously an orphan was left running)

Medium

  • BufferedObservableProcess.KickOff — TOCTOU race: process could exit between HasExited check and Exited += registration, causing WaitForCompletion to hang indefinitely; fixed by registering handler first, re-checking HasExited after
  • BufferedObservableProcess.CancelAsyncReads — cancelled CancellationTokenSource was replaced but never disposed (OS handle leak on every cancel/start cycle)
  • ObservableProcessBase.Dispose_isDisposing was not volatile and was reset to false after Stop(), creating a window for late ExitStop calls to race in; now volatile bool _disposing that is never reset; Dispose() also acquires _exitLock to prevent concurrent Stop() from both ExitStop and Dispose()
  • LongRunningApplicationSubscriptionWaitHandle (ManualResetEvent) was never disposed; also changed from internal to public so callers can wait on it directly

Low

  • _sentControlC — replaced bool with int + Interlocked.CompareExchange for a thread-safe single-fire guarantee
  • CancellationToken supportWaitForCompletion, Proc.Start, Proc.StartRedirected, and Proc.StartLongRunning all now accept an optional CancellationToken; all are backward-compatible additions
  • Duplicated HardWaitForExit — three near-identical implementations consolidated into ProcessWaitExtensions.HardWaitForExit(this Process, TimeSpan)

Test plan

  • dotnet test passes (48 passed, 4 skipped — Windows-only ControlC tests)
  • StartRedirectedBugTests — proves recursion fix (previously crashed the test runner)
  • ExecAsyncBugTests — proves CancellationToken is respected within 500ms, and child process is dead after cancellation (PID file check)
  • ExecBugTests — proves child process is dead after timeout (PID file check)
  • BufferedObservableProcessBugTests — 30 fast-exiting processes all complete their observable (TOCTOU stress test)
  • StartLongRunningBugTestsWaitHandle.WaitOne() throws ObjectDisposedException after dispose
  • ObservableProcessBaseTests — 30 concurrent exit+dispose iterations without exceptions
  • CancellationTokenTestsStart, StartRedirected, StartLongRunning, and WaitForCompletion all abort promptly on cancellation

🤖 Generated with Claude Code

Mpdreamz and others added 2 commits March 28, 2026 22:29
Critical/High:
- Fix infinite recursion in StartRedirected(IConsoleLineHandler, string, params string[])
- ExecAsync: replace sync WaitForExit(int) + ignored CancellationToken with linked
  CancellationTokenSource; kill process on timeout and on caller cancellation;
  remove broken HardWaitForExitAsync
- Exec: kill process before HardWaitForExit on timeout (was leaving orphan processes)

Medium:
- BufferedObservableProcess: fix TOCTOU race where process exit between HasExited check
  and Exited+= registration caused WaitForCompletion to hang; register handler first,
  re-check HasExited after
- BufferedObservableProcess: dispose old CancellationTokenSource in CancelAsyncReads
  instead of leaking it
- ObservableProcessBase: make _disposing volatile and never reset it; wrap Dispose()
  in _exitLock so concurrent Exited event + Dispose() cannot both enter Stop()
- StartLongRunning: dispose WaitHandle (ManualResetEvent) in Dispose(); expose it as
  public so callers can also wait on it

Low:
- ObservableProcessBase: replace _sentControlC bool with int + Interlocked.CompareExchange
  for thread-safe single-fire guarantee
- Add CancellationToken support to WaitForCompletion, Proc.Start, Proc.StartRedirected,
  and Proc.StartLongRunning (backward-compatible optional parameter)
- Extract shared HardWaitForExit into ProcessWaitExtensions to remove three near-identical
  implementations

Also add regression tests for each fix (one test file per source file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CI nupkg-validator tool (0.10.1) bundles FSharp.Core 10.0.100. The
updated .NET SDK on the CI runner now compiles Proc.Fs.dll against
FSharp.Core 10.1.x, causing the validator to fail when loading the DLL.
Pinning to 10.0.100 keeps the compiled DLL compatible with the validator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Mpdreamz Mpdreamz merged commit 5e2f6ef into master Mar 30, 2026
1 check passed
@Mpdreamz Mpdreamz deleted the fix/process-lifecycle-bugs branch March 30, 2026 10:06
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