From d7885828cadbd33cf84af02e6d54c86f630b22dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:33:49 +0000 Subject: [PATCH 1/4] Replace the PID file atomically and treat contention as another instance [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 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- README.md | 2 +- .../SingleAppInstanceTests.cs | 176 +++++++++++++++ SingleAppInstance/SingleAppInstance.cs | 204 +++++++++++++++--- 3 files changed, 349 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 7423678..ccaab7b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - **Single Instance Enforcement**: Prevents multiple copies of your application from running simultaneously - **Enhanced Process Identification**: Verifies running instances using multiple attributes (PID, process name, start time, executable path) for accurate detection - **Race Condition Handling**: Includes a built-in 1-second delay to safely detect simultaneous startup attempts -- **PID File Management**: Stores process information as JSON in the application data directory +- **PID File Management**: Stores process information as JSON in the application data directory, replacing the file atomically so a racing instance never reads a partial write, and treating a PID file another instance is holding as that instance starting rather than throwing - **Backward Compatibility**: Gracefully handles legacy PID files that stored only a plain integer PID, confirming the process name before treating a recycled PID as a running instance - **Simple API**: Two methods — `ExitIfAlreadyRunning()` for automatic exit and `ShouldLaunch()` for custom logic - **Multi-Target Support**: Works across .NET 10.0 through .NET 5.0, .NET Standard 2.0/2.1 diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 871d8b9..e7a4a89 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -389,6 +389,171 @@ public void ShouldLaunch_WhenAlreadyRunning_ShouldReturnFalse() Assert.IsFalse(result, "ShouldLaunch should return false when another instance is detected"); } + [TestMethod] + public void IsAlreadyRunning_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnTrue() + { + // Arrange - two writers racing leave the shorter record followed by the tail of the longer + // one, and the record still describes the live instance that wrote it + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("TornPidHelper"); + + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}"); + + // Act + bool result = SingleAppInstance.IsAlreadyRunning(); + + // Assert + Assert.IsTrue(result, "A torn PID file whose leading record describes a running instance should read as that instance"); + } + + [TestMethod] + public void ShouldLaunch_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnFalse() + { + // Arrange + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("TornPidHelper"); + + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}"); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + + // Assert + Assert.IsFalse(result, "A torn PID file must not let a second instance launch while the first is running"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFileBecomesUnreadableDuringRaceWindow_ShouldReturnFalse() + { + // Arrange - once this instance has written its PID file, a racing instance leaves content + // that cannot be parsed, which must count as that instance rather than as no instance + string pidFilePath = SingleAppInstance.PidFilePath; + string ownPid = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + + Task racingWriter = Task.Run(() => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(10)) + { + try + { + if (File.ReadAllText(pidFilePath).Contains(ownPid, StringComparison.Ordinal)) + { + File.WriteAllText(pidFilePath, "{\"ProcessId\":12"); + return; + } + } + catch (IOException) + { + // Not written yet, or being replaced + } + catch (UnauthorizedAccessException) + { + // Being replaced + } + + Thread.Sleep(10); + } + }); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + racingWriter.Wait(); + + // Assert + Assert.IsFalse(result, "Unparseable content after this instance wrote its PID file should not grant a launch"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFileIsHeldExclusively_ShouldReturnFalseWithoutThrowing() + { + // Arrange - another instance holding the PID file open is what a simultaneous launch looks like + string pidFilePath = SingleAppInstance.PidFilePath; + using FileStream heldPidFile = new(pidFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + + // Assert + Assert.IsFalse(result, "A PID file held by another instance should read as that instance starting"); + } + + [TestMethod] + public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile() + { + // Arrange - a reader racing a writer must only ever see a whole PID file + string pidFilePath = SingleAppInstance.PidFilePath; + SingleAppInstance.WritePidFile(); + + using CancellationTokenSource writing = new(); + Task writer = Task.Run(() => + { + while (!writing.IsCancellationRequested) + { + try + { + SingleAppInstance.WritePidFile(); + } + catch (IOException) + { + // The reader held the file for every retry; contention is expected here + } + catch (UnauthorizedAccessException) + { + // The reader held the file for every retry; contention is expected here + } + } + }); + + int partialReads = 0; + string? lastPartialContent = null; + + // Act + try + { + for (int i = 0; i < 2000; i++) + { + string content; + try + { + content = File.ReadAllText(pidFilePath); + } + catch (IOException) + { + // The file was being replaced; a sharing violation is not a partial read + continue; + } + catch (UnauthorizedAccessException) + { + // The file was being replaced; a pending delete is not a partial read + continue; + } + + try + { + if (JsonSerializer.Deserialize(content) is null) + { + partialReads++; + lastPartialContent = content; + } + } + catch (JsonException) + { + partialReads++; + lastPartialContent = content; + } + } + } + finally + { + writing.Cancel(); + writer.Wait(); + } + + // Assert + Assert.AreEqual(0, partialReads, $"Every read should see a whole PID file; last partial content was '{lastPartialContent}'"); + } + [TestMethod] public void PidDirectoryPath_ShouldNotBeEmpty() { @@ -491,6 +656,17 @@ public void IsAlreadyRunning_WithJsonArrayInPidFile_ShouldReturnFalse() Assert.IsFalse(result); } + /// + /// Describes a running process the way records one. + /// + private static ProcessInfo DescribeProcess(Process process) => new() + { + ProcessId = process.Id, + ProcessName = process.ProcessName, + StartTime = process.StartTime, + MainModuleFileName = process.MainModule?.FileName, + }; + /// /// Finds a running process that is neither the current process nor shares its process name. /// diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index f7803f2..d56ac1f 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -4,6 +4,7 @@ namespace ktsu.SingleAppInstance; using System.Diagnostics; using System.Globalization; +using System.Text; using System.Text.Json; using ktsu.AppDataStorage; @@ -44,6 +45,8 @@ public static void ExitIfAlreadyRunning() /// If no other instance is running, it writes the current process ID to a PID file /// and waits for a short period to handle potential race conditions. It then checks /// again to ensure no other instance started during the wait period. + /// If the PID file stays locked by another process, or cannot be written, another instance + /// is taken to be starting and this method returns false rather than throwing. /// public static bool ShouldLaunch() { @@ -55,14 +58,61 @@ public static bool ShouldLaunch() // if no other instance is running, write our pid to the pid file and wait to see // if another instance was attempting to start at the same time - WritePidFile(); + try + { + WritePidFile(); + } + catch (IOException) + { + // Another instance kept the PID file busy for the whole retry window + return false; + } + catch (UnauthorizedAccessException) + { + // The PID file cannot be replaced, so this instance cannot claim it + return false; + } + Thread.Sleep(1000); // in case there was a race and another instance is starting at the same time we - // need to check again to see if we won the lock - return !IsAlreadyRunning(); + // need to check again to see if we won the lock. We just wrote a whole PID file, so + // content that cannot be read now was written by an instance racing us, and it counts + // as that instance rather than as no instance at all + return ReadPidFileState() == PidFileState.NoInstance; } + /// + /// What the PID file says about other instances of the application. + /// + internal enum PidFileState + { + /// + /// No other instance is running. + /// + NoInstance, + + /// + /// Another instance is running, or is holding the PID file while it starts. + /// + AnotherInstance, + + /// + /// The PID file exists but its contents cannot be understood. + /// + Unreadable, + } + + /// + /// How many times the PID file is read or replaced before contention is taken to be another instance. + /// + private const int PidFileAttempts = 5; + + /// + /// How long to wait before each retry, multiplied by the number of attempts made so far. + /// + private static readonly TimeSpan PidFileRetryDelay = TimeSpan.FromMilliseconds(20); + /// /// Represents process information stored in the PID file. /// @@ -99,29 +149,51 @@ internal class ProcessInfo /// This method reads the PID file to get the process information of the running instance. /// It then checks if the process with that ID is still running and verifies it's the same application. /// - internal static bool IsAlreadyRunning() + internal static bool IsAlreadyRunning() => ReadPidFileState() == PidFileState.AnotherInstance; + + /// + /// Reads the PID file and determines whether it describes another running instance. + /// + /// What the PID file says about other instances of the application. + /// + /// Another instance may be writing the PID file at the same moment, which on some platforms + /// makes the read fail with a sharing violation. The read is retried briefly, and if the file + /// stays inaccessible it is treated as another instance that is starting. + /// + internal static PidFileState ReadPidFileState() { int currentPid = GetCurrentProcessId(); - try - { - string pidFileContents = File.ReadAllText(PidFilePath); - return CheckPidFileContents(pidFileContents, currentPid); - } - catch (DirectoryNotFoundException) - { - // PID directory doesn't exist yet - no instance running - } - catch (FileNotFoundException) - { - // PID file doesn't exist - no instance running - } - catch (FormatException) + for (int attempt = 1; attempt <= PidFileAttempts; attempt++) { - // PID file content is corrupted - treat as no instance running + try + { + string pidFileContents = File.ReadAllText(PidFilePath); + return CheckPidFileContents(pidFileContents, currentPid); + } + catch (DirectoryNotFoundException) + { + // PID directory doesn't exist yet - no instance running + return PidFileState.NoInstance; + } + catch (FileNotFoundException) + { + // PID file doesn't exist - no instance running + return PidFileState.NoInstance; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Another instance is writing or holding the PID file + } + + if (attempt < PidFileAttempts) + { + Thread.Sleep(TimeSpan.FromTicks(PidFileRetryDelay.Ticks * attempt)); + } } - return false; + // The PID file stayed inaccessible, so another instance is holding it while it starts + return PidFileState.AnotherInstance; } /// @@ -145,16 +217,22 @@ private static int GetCurrentProcessId() /// /// The raw contents of the PID file. /// The current process ID. - /// true if a different instance of the application is running; otherwise, false. - private static bool CheckPidFileContents(string pidFileContents, int currentPid) + /// What the contents say about other instances of the application. + /// + /// Only the first JSON value in the file is read. A PID file torn by two older writers racing + /// holds a complete record followed by the tail of a longer one, and that record still names the + /// instance that wrote it. + /// + private static PidFileState CheckPidFileContents(string pidFileContents, int currentPid) { ProcessInfo? storedProcess; try { - storedProcess = JsonSerializer.Deserialize(pidFileContents); + Utf8JsonReader reader = new(Encoding.UTF8.GetBytes(pidFileContents)); + storedProcess = JsonSerializer.Deserialize(ref reader); if (storedProcess == null) { - return false; + return PidFileState.NoInstance; } } catch (JsonException) @@ -164,31 +242,38 @@ private static bool CheckPidFileContents(string pidFileContents, int currentPid) if (storedProcess.ProcessId == currentPid) { - return false; + return PidFileState.NoInstance; } - return IsStoredProcessRunning(storedProcess); + return ToState(IsStoredProcessRunning(storedProcess)); } + /// + /// Converts the outcome of a process check into a PID file state. + /// + /// Whether another instance was found running. + /// The corresponding PID file state. + private static PidFileState ToState(bool isRunning) => isRunning ? PidFileState.AnotherInstance : PidFileState.NoInstance; + /// /// Handles backward-compatible legacy PID files that contain only a plain integer PID. /// /// The raw contents of the PID file. /// The current process ID. - /// true if the legacy PID corresponds to a running instance of this application; otherwise, false. - private static bool HandleLegacyPidFile(string pidFileContents, int currentPid) + /// What the legacy PID says about other instances of the application. + private static PidFileState HandleLegacyPidFile(string pidFileContents, int currentPid) { if (!int.TryParse(pidFileContents, NumberStyles.Integer, CultureInfo.InvariantCulture, out int filePid)) { - return false; + return PidFileState.Unreadable; } if (filePid == currentPid) { - return false; + return PidFileState.NoInstance; } - return IsLegacyProcessRunning(filePid); + return ToState(IsLegacyProcessRunning(filePid)); } /// @@ -333,7 +418,12 @@ private static bool IsSameApplicationName(string runningProcessName, string curr /// /// /// This method writes the current process information to the PID file in the application data path. + /// The record is written to a temporary file beside the PID file and then moved over it, so a reader + /// sees either the previous PID file or the new one and never a partial or interleaved write. + /// Replacing the file is retried briefly while another instance holds it. /// + /// The PID file stayed in use for every attempt. + /// The PID file could not be replaced. internal static void WritePidFile() { Directory.CreateDirectory(PidDirectoryPath); @@ -348,6 +438,56 @@ internal static void WritePidFile() }; string json = JsonSerializer.Serialize(processInfo); - File.WriteAllText(PidFilePath, json); + string pidFilePath = PidFilePath; + string temporaryPath = $"{pidFilePath}.{Guid.NewGuid():N}.tmp"; + + try + { + File.WriteAllText(temporaryPath, json); + + for (int attempt = 1; ; attempt++) + { + try + { + ReplacePidFile(temporaryPath, pidFilePath); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && attempt < PidFileAttempts) + { + // Another instance is reading or replacing the PID file + } + + Thread.Sleep(TimeSpan.FromTicks(PidFileRetryDelay.Ticks * attempt)); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Moves a fully written temporary file over the PID file in a single operation. + /// + /// The fully written temporary file. + /// The PID file to create or replace. +#if NETCOREAPP3_0_OR_GREATER + private static void ReplacePidFile(string temporaryPath, string pidFilePath) => + File.Move(temporaryPath, pidFilePath, overwrite: true); +#else + private static void ReplacePidFile(string temporaryPath, string pidFilePath) + { + if (File.Exists(pidFilePath)) + { + File.Replace(temporaryPath, pidFilePath, destinationBackupFileName: null); + } + else + { + File.Move(temporaryPath, pidFilePath); + } } +#endif } From da89aa4f4524bc7c01d40cc8729ebb060f4efddd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:35:13 +0000 Subject: [PATCH 2/4] Wait for the helper's main module before describing it in tests 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 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index e7a4a89..ea7ede4 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -777,7 +777,38 @@ public static HelperProcess Start(string processName) Process? process = Process.Start(startInfo); Assert.IsNotNull(process, "Should be able to start a helper process"); - return new HelperProcess(process, temporaryDirectory); + HelperProcess helper = new(process, temporaryDirectory); + helper.WaitForMainModule(); + return helper; + } + + /// + /// Waits until the helper's main module can be read, since Windows reports none until the + /// loader has finished starting the process, and a test that records it too early would + /// describe a different process from the one IsAlreadyRunning later inspects. + /// + private void WaitForMainModule() + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(10)) + { + Process.Refresh(); + try + { + if (Process.MainModule?.FileName is not null) + { + return; + } + } + catch (Win32Exception) + { + // The module list is not readable yet + } + + Thread.Sleep(50); + } + + Assert.Inconclusive("The helper process's main module never became readable"); } public void Dispose() From f1c77e86f32a38f3d8465a4b617ae2b22bcfd53d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:48:44 +0000 Subject: [PATCH 3/4] Test the PID file write failing, and report it through TryWritePidFile 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 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 68 +++++++++++++++++++ SingleAppInstance/SingleAppInstance.cs | 42 +++++++----- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index ea7ede4..3b32c8b 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -18,6 +18,11 @@ public void TestInitialize() // Ensure the PID directory exists and the file is deleted before each test string pidFilePath = SingleAppInstance.PidFilePath; Directory.CreateDirectory(SingleAppInstance.PidDirectoryPath); + if (Directory.Exists(pidFilePath)) + { + Directory.Delete(pidFilePath, recursive: true); + } + File.Delete(pidFilePath); } @@ -554,6 +559,69 @@ public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile() Assert.AreEqual(0, partialReads, $"Every read should see a whole PID file; last partial content was '{lastPartialContent}'"); } + [TestMethod] + public void WritePidFile_WhenPidFileCannotBeReplaced_ShouldThrowAndRemoveTemporaryFile() + { + // Arrange - a directory where the PID file belongs can never be replaced by a file + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + bool threw = false; + + // Act + try + { + SingleAppInstance.WritePidFile(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + threw = true; + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsTrue(threw, "WritePidFile should throw once every attempt to replace the PID file has failed"); + string[] temporaryFiles = Directory.GetFiles(SingleAppInstance.PidDirectoryPath, Path.GetFileName(pidFilePath) + ".*.tmp"); + Assert.IsEmpty(temporaryFiles, "The temporary file should be removed when the PID file cannot be replaced"); + } + + [TestMethod] + public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldReturnFalse() + { + // Arrange + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + + // Act + bool result; + try + { + result = SingleAppInstance.TryWritePidFile(); + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsFalse(result, "TryWritePidFile should report that this instance could not claim the PID file"); + } + + [TestMethod] + public void TryWritePidFile_WhenPidFileCanBeWritten_ShouldReturnTrue() + { + // Act + bool result = SingleAppInstance.TryWritePidFile(); + + // Assert + Assert.IsTrue(result); + ProcessInfo? processInfo = JsonSerializer.Deserialize(File.ReadAllText(SingleAppInstance.PidFilePath)); + Assert.IsNotNull(processInfo); + Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId); + } + [TestMethod] public void PidDirectoryPath_ShouldNotBeEmpty() { diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index d56ac1f..fef0c7b 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -58,18 +58,8 @@ public static bool ShouldLaunch() // if no other instance is running, write our pid to the pid file and wait to see // if another instance was attempting to start at the same time - try - { - WritePidFile(); - } - catch (IOException) - { - // Another instance kept the PID file busy for the whole retry window - return false; - } - catch (UnauthorizedAccessException) + if (!TryWritePidFile()) { - // The PID file cannot be replaced, so this instance cannot claim it return false; } @@ -171,14 +161,9 @@ internal static PidFileState ReadPidFileState() string pidFileContents = File.ReadAllText(PidFilePath); return CheckPidFileContents(pidFileContents, currentPid); } - catch (DirectoryNotFoundException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { - // PID directory doesn't exist yet - no instance running - return PidFileState.NoInstance; - } - catch (FileNotFoundException) - { - // PID file doesn't exist - no instance running + // The PID file or its directory doesn't exist - no instance running return PidFileState.NoInstance; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -469,6 +454,27 @@ internal static void WritePidFile() } } + /// + /// Writes the current process information to the PID file, reporting failure instead of throwing. + /// + /// true if the PID file now describes this process; otherwise, false. + /// + /// The PID file stays in use when another instance keeps it busy for the whole retry window, + /// and cannot be replaced at all when access is denied. Either way this instance cannot claim it. + /// + internal static bool TryWritePidFile() + { + try + { + WritePidFile(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + /// /// Moves a fully written temporary file over the PID file in a single operation. /// From f67bef234e2d79a743e5d5100ea75e9369b98f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:50:09 +0000 Subject: [PATCH 4/4] Bound the PID file replace loop, and resolve the test analyzer findings 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 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 19 +++++++++++-------- SingleAppInstance/SingleAppInstance.cs | 7 +++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 3b32c8b..83f82b2 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -12,6 +12,8 @@ namespace ktsu.SingleAppInstance.Test; [DoNotParallelize] public class SingleAppInstanceTests { + public TestContext TestContext { get; set; } = null!; + [TestInitialize] public void TestInitialize() { @@ -435,16 +437,17 @@ public void ShouldLaunch_WhenPidFileBecomesUnreadableDuringRaceWindow_ShouldRetu string pidFilePath = SingleAppInstance.PidFilePath; string ownPid = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); - Task racingWriter = Task.Run(() => + Task racingWriter = Task.Run(async () => { Stopwatch stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed < TimeSpan.FromSeconds(10)) { try { - if (File.ReadAllText(pidFilePath).Contains(ownPid, StringComparison.Ordinal)) + string content = await File.ReadAllTextAsync(pidFilePath, TestContext.CancellationToken).ConfigureAwait(false); + if (content.Contains(ownPid, StringComparison.Ordinal)) { - File.WriteAllText(pidFilePath, "{\"ProcessId\":12"); + await File.WriteAllTextAsync(pidFilePath, "{\"ProcessId\":12", TestContext.CancellationToken).ConfigureAwait(false); return; } } @@ -457,13 +460,13 @@ public void ShouldLaunch_WhenPidFileBecomesUnreadableDuringRaceWindow_ShouldRetu // Being replaced } - Thread.Sleep(10); + await Task.Delay(10, TestContext.CancellationToken).ConfigureAwait(false); } - }); + }, TestContext.CancellationToken); // Act bool result = SingleAppInstance.ShouldLaunch(); - racingWriter.Wait(); + racingWriter.Wait(TestContext.CancellationToken); // Assert Assert.IsFalse(result, "Unparseable content after this instance wrote its PID file should not grant a launch"); @@ -508,7 +511,7 @@ public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile() // The reader held the file for every retry; contention is expected here } } - }); + }, TestContext.CancellationToken); int partialReads = 0; string? lastPartialContent = null; @@ -552,7 +555,7 @@ public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile() finally { writing.Cancel(); - writer.Wait(); + writer.Wait(TestContext.CancellationToken); } // Assert diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index fef0c7b..6aa4d40 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -430,20 +430,23 @@ internal static void WritePidFile() { File.WriteAllText(temporaryPath, json); - for (int attempt = 1; ; attempt++) + for (int attempt = 1; attempt < PidFileAttempts; attempt++) { try { ReplacePidFile(temporaryPath, pidFilePath); return; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && attempt < PidFileAttempts) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Another instance is reading or replacing the PID file } Thread.Sleep(TimeSpan.FromTicks(PidFileRetryDelay.Ticks * attempt)); } + + // The final attempt lets a persistent failure reach the caller + ReplacePidFile(temporaryPath, pidFilePath); } finally {