Skip to content

Replace the PID file atomically and treat contention as another instance [patch] - #186

Merged
matt-edmondson merged 4 commits into
mainfrom
fix/181-182-pid-file-races
Sep 28, 2026
Merged

matt-edmondson merged 4 commits into
mainfrom
fix/181-182-pid-file-races

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #181
Fixes #182

Both issues come from the same concurrent PID-file race, and both triage comments recommend fixing them together. They also rewrite the same two methods (WritePidFile and the read in IsAlreadyRunning), so separate PRs would conflict with each other.

What was wrong

Change

  • Atomic replace. WritePidFile writes to <pid>.<guid>.tmp beside the PID file and moves it over the PID file: File.Move(…, overwrite: true) on .NET Core 3.0 and later, File.Replace/File.Move on netstandard. Readers see either the old file or the new one. The replace is retried up to 5 times with a short linear backoff while another instance holds the file. The temp file is always cleaned up.
  • Contention means another instance. The read retries on IOException/UnauthorizedAccessException. If the file stays inaccessible, it reads as AnotherInstance. If WritePidFile still can't replace the file, ShouldLaunch() returns false rather than throwing.
  • Torn files from older versions. Only the leading JSON value is parsed (Utf8JsonReader + Deserialize(ref reader)). A record followed by garbage still names the instance that wrote it, and the full identity check still applies to it. Legacy plain-integer files and all the existing malformed-content cases behave as before.
  • Recheck fails closed. A new internal PidFileState (NoInstance / AnotherInstance / Unreadable) lets the post-sleep recheck in ShouldLaunch() tell the cases apart. Content that can't be parsed right after we wrote a whole file came from a racing instance, so it no longer counts as "no instance". The first check still treats unparseable content as stale, so a leftover corrupt file doesn't block launch forever. IsAlreadyRunning() keeps its signature and existing behaviour.
  • One README feature bullet was updated to describe the atomic write and the contention behaviour.

Tests

Five new tests. All five fail with the library change reverted and pass with it:

Test Covers
IsAlreadyRunning_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnTrue #181: a torn file describing a live process reads as that instance
ShouldLaunch_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnFalse #181 acceptance: a garbage-suffixed PID file for a live instance doesn't grant a second launch
ShouldLaunch_WhenPidFileBecomesUnreadableDuringRaceWindow_ShouldReturnFalse #181: unparseable content during the recheck window doesn't grant a launch
WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile #181 acceptance: the file is only ever replaced atomically (2000 reads racing a writer)
ShouldLaunch_WhenPidFileIsHeldExclusively_ShouldReturnFalseWithoutThrowing #182 acceptance: an exclusively held PID file gets a bool back, not an exception
  • dotnet test: 31/31 passed on Linux (net10.0), three runs in a row with no flakes. The build is clean on every target framework with the repo's analyzers, including a CA1508 finding fixed along the way.
  • Windows-specific sharing behaviour (the MoveFileEx replace failing while a reader has the file open) is covered by the retry logic, but I have only run it on Linux. The race tests tolerate sharing violations and don't count them as partial reads, so they should hold on the Windows and macOS CI legs.

This PR merges cleanly with #185 (for #180) in either order. I checked this with a local git merge-tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx


Generated by Claude Code

…nce [patch]

WritePidFile wrote the file in place, so two instances starting together could
tear it, and a torn file read as "no instance", letting both launch. Reading or
writing the file while another instance held it threw an IOException out of
ShouldLaunch instead of returning a decision.

- WritePidFile writes a temporary file beside the PID file and moves it over,
  retrying briefly while another instance holds the file
- Reads retry briefly on IOException/UnauthorizedAccessException; a file that
  stays inaccessible reads as another instance starting
- Only the leading JSON record is parsed, so a file torn by older versions
  still names the instance that wrote it
- The post-sleep recheck treats unparseable content as a racing instance
  rather than as no instance, and ShouldLaunch returns false if the PID file
  cannot be written

Fixes #181
Fixes #182

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx
On Windows a freshly started process reports no main module until the loader
has finished, so the tests recorded a null module path and then compared it
against the loaded one. The helper now waits until its main module is readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx
A directory where the PID file belongs can never be replaced, which drives
WritePidFile through every retry to its exception and checks that the
temporary file is removed. ShouldLaunch now goes through TryWritePidFile, so
the not-claimed outcome is tested directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx
The replace loop had no stop condition of its own (Sonar S1994); it now retries
a bounded number of times and makes the final attempt outside the loop, so a
persistent failure still reaches the caller. The race tests pass the test's
cancellation token and wait with Task.Delay instead of Thread.Sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 7418520 into main Sep 28, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the fix/181-182-pid-file-races branch September 28, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants