From 5ba4cd2c5300a9f28c86d2fd5e8786cf97c29667 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:27:19 +0000 Subject: [PATCH 1/2] Throw for a PID file that can't be read or written instead of reporting another instance [patch] An access failure on the PID file or its folder, such as one left owned by root after a run under sudo, was read as another instance running, so the app exited 0 on every launch with no message. Only a lock held by another process now counts as another instance. Any other failure that outlasts the retries is thrown, naming the PID file. Fixes #187 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2iZzaJLpHHri8a4vS --- README.md | 2 +- .../SingleAppInstanceTests.cs | 101 ++++++++++++++- SingleAppInstance/SingleAppInstance.cs | 117 ++++++++++++++++-- 3 files changed, 206 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ccaab7b..9836f04 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, 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 +- **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. A PID file or folder the current user cannot read or write, such as one a run under `sudo` left owned by root, throws an `UnauthorizedAccessException` or `IOException` naming the file instead of being mistaken for another instance - **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 ea5909f..5141695 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -686,17 +686,76 @@ public void WritePidFile_WhenPidFileCannotBeReplaced_ShouldThrowAndRemoveTempora } [TestMethod] - public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldReturnFalse() + public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldThrowNamingThePidFile() + { + // Arrange - a directory where the PID file belongs is a lasting fault, not another instance + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + + // Act + Exception? failure = null; + try + { + SingleAppInstance.TryWritePidFile(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failure = ex; + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsNotNull(failure, "A PID file that can never be written must not read as another instance claiming it"); + Assert.Contains(pidFilePath, failure.Message, "The failure should name the PID file"); + } + + [TestMethod] + public void IsAlreadyRunning_WhenPidFileCannotBeRead_ShouldThrow() + { + // Arrange - reading a directory fails with access denied on every platform, and keeps failing + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + + // Act + Exception? failure = null; + try + { + SingleAppInstance.IsAlreadyRunning(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failure = ex; + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsNotNull(failure, "A PID file that can never be read must not read as another instance running"); + Assert.Contains(pidFilePath, failure.Message, "The failure should name the PID file"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFileCannotBeReadOrWritten_ShouldThrowInsteadOfReturningFalse() { // Arrange string pidFilePath = SingleAppInstance.PidFilePath; Directory.CreateDirectory(pidFilePath); // Act - bool result; + bool? result = null; + bool threw = false; try { - result = SingleAppInstance.TryWritePidFile(); + result = SingleAppInstance.ShouldLaunch(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + threw = true; } finally { @@ -704,7 +763,41 @@ public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldReturnFalse() } // Assert - Assert.IsFalse(result, "TryWritePidFile should report that this instance could not claim the PID file"); + Assert.IsTrue(threw, $"ShouldLaunch should report an unusable PID file, but returned {result}"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFolderIsReadOnly_ShouldThrowInsteadOfReturningFalse() + { + // Arrange - a folder left owned by another user, as a run under sudo leaves it + if (OperatingSystem.IsWindows() || Environment.IsPrivilegedProcess) + { + Assert.Inconclusive("Folder permissions only deny an unprivileged process on Unix"); + return; + } + + string pidDirectoryPath = SingleAppInstance.PidDirectoryPath; + UnixFileMode originalMode = File.GetUnixFileMode(pidDirectoryPath); + File.SetUnixFileMode(pidDirectoryPath, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + // Act + bool? result = null; + bool threw = false; + try + { + result = SingleAppInstance.ShouldLaunch(); + } + catch (UnauthorizedAccessException) + { + threw = true; + } + finally + { + File.SetUnixFileMode(pidDirectoryPath, originalMode); + } + + // Assert + Assert.IsTrue(threw, $"ShouldLaunch should report a PID folder it cannot write to, but returned {result}"); } [TestMethod] diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index 2dda7d4..c742cbf 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -4,6 +4,7 @@ namespace ktsu.SingleAppInstance; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; @@ -26,6 +27,8 @@ public static class SingleAppInstance /// This method checks if another instance of the application is already running by calling . /// If another instance is detected, the current application exits with a status code of 0. /// + /// The PID file or its folder cannot be read or written. + /// Access to the PID file or its folder is denied. public static void ExitIfAlreadyRunning() { if (!ShouldLaunch()) @@ -45,9 +48,13 @@ 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. + /// If the PID file stays locked by another process, another instance is taken to be starting + /// and this method returns false rather than throwing. Any other failure to read or write + /// the PID file, such as a file or folder left owned by another user, is a lasting fault rather + /// than a sign of another instance, so it is thrown instead of being reported as one. /// + /// The PID file or its folder cannot be read or written. + /// Access to the PID file or its folder is denied. public static bool ShouldLaunch() { // if there is already an instance running, exit @@ -148,11 +155,15 @@ internal class ProcessInfo /// /// 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. + /// stays locked it is treated as another instance that is starting. Any other failure that + /// outlasts the retries is thrown, because it will not clear by itself. /// + /// The PID file cannot be read for a reason other than a lock. + /// Access to the PID file is denied. internal static PidFileState ReadPidFileState() { int currentPid = GetCurrentProcessId(); + Exception? lastFailure = null; for (int attempt = 1; attempt <= PidFileAttempts; attempt++) { @@ -168,7 +179,8 @@ internal static PidFileState ReadPidFileState() } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Another instance is writing or holding the PID file + // Another instance may be writing or holding the PID file + lastFailure = ex; } if (attempt < PidFileAttempts) @@ -177,8 +189,88 @@ internal static PidFileState ReadPidFileState() } } - // The PID file stayed inaccessible, so another instance is holding it while it starts - return PidFileState.AnotherInstance; + // A PID file that stayed locked is another instance holding it while it starts + if (lastFailure is IOException ioException && IsLockViolation(ioException)) + { + return PidFileState.AnotherInstance; + } + + throw PidFileFault("read", lastFailure!); + } + + /// + /// The Win32 error a file open fails with when another process has the file open without sharing it. + /// + private const int ErrorSharingViolation = 0x20; + + /// + /// The Win32 error a file operation fails with when another process has locked part of the file. + /// + private const int ErrorLockViolation = 0x21; + + /// + /// The EWOULDBLOCK errno on Linux, which .NET reports when another process holds the file's lock. + /// + private const int LinuxWouldBlock = 11; + + /// + /// The EWOULDBLOCK errno on macOS and FreeBSD, which .NET reports when another process holds the file's lock. + /// + private const int BsdWouldBlock = 35; + + /// + /// Checks whether an I/O failure was caused by another process holding the file. + /// + /// The I/O failure. + /// true for a sharing or lock violation; otherwise, false. + /// + /// Windows reports these as an HRESULT wrapping a Win32 error. On Unix, .NET locks a file opened + /// with with flock, and reports another process holding that lock + /// with the bare EWOULDBLOCK errno. On a Unix whose errno is not known here, every I/O failure + /// is still taken to be another instance, as it was before access failures were told apart. + /// + private static bool IsLockViolation(IOException exception) + { + int code = exception.HResult; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return (code & 0xFFFF) is ErrorSharingViolation or ErrorLockViolation; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return code == LinuxWouldBlock; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || IsFreeBsd()) + { + return code == BsdWouldBlock; + } + + return true; + } + + /// + /// Checks whether the current platform is FreeBSD. + /// + /// true on FreeBSD; otherwise, false. + private static bool IsFreeBsd() => RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD")); + + /// + /// Describes a failure to use the PID file that does not come from another instance. + /// + /// What was being done with the PID file. + /// The failure that outlasted the retries. + /// An exception of the same kind as that names the PID file. + private static Exception PidFileFault(string operation, Exception failure) + { + string message = $"SingleAppInstance could not {operation} its PID file '{PidFilePath}': {failure.Message} " + + "This is not another instance running. Check that the current user can read and write the file and its folder; " + + "a run as another user, such as under sudo, can leave them owned by that user."; + + return failure is UnauthorizedAccessException + ? new UnauthorizedAccessException(message, failure) + : new IOException(message, failure); } /// @@ -509,9 +601,12 @@ internal static void WritePidFile() /// /// 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. + /// The PID file stays locked when another instance keeps it busy for the whole retry window, + /// and this instance cannot claim it. Any other failure, such as access being denied, is a + /// lasting fault rather than another instance, and is thrown. /// + /// The PID file or its folder cannot be written for a reason other than a lock. + /// Access to the PID file or its folder is denied. internal static bool TryWritePidFile() { try @@ -519,10 +614,14 @@ internal static bool TryWritePidFile() WritePidFile(); return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (IOException ex) when (IsLockViolation(ex)) { return false; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw PidFileFault("write", ex); + } } /// From 93257ef06b8646cde4610bd85973aef33ea68f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:36:17 +0000 Subject: [PATCH 2/2] Test the lock-error check for every platform, not just the one CI runs on The check now takes the platform as a parameter, so each platform's lock error code is covered whichever operating system runs the tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2iZzaJLpHHri8a4vS --- .../SingleAppInstanceTests.cs | 24 +++++++++++++ SingleAppInstance/SingleAppInstance.cs | 36 +++++++++++++------ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 5141695..6f0095a 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -712,6 +712,30 @@ public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldThrowNamingThePidF Assert.Contains(pidFilePath, failure.Message, "The failure should name the PID file"); } + [TestMethod] + [DataRow("WINDOWS", unchecked((int)0x80070020), true, DisplayName = "Windows sharing violation")] + [DataRow("WINDOWS", unchecked((int)0x80070021), true, DisplayName = "Windows lock violation")] + [DataRow("WINDOWS", unchecked((int)0x80070005), false, DisplayName = "Windows access denied")] + [DataRow("LINUX", 11, true, DisplayName = "Linux EWOULDBLOCK")] + [DataRow("LINUX", 13, false, DisplayName = "Linux EACCES")] + [DataRow("LINUX", 35, false, DisplayName = "Linux errno 35 is not EWOULDBLOCK")] + [DataRow("OSX", 35, true, DisplayName = "macOS EWOULDBLOCK")] + [DataRow("OSX", 11, false, DisplayName = "macOS errno 11 is not EWOULDBLOCK")] + [DataRow("FREEBSD", 35, true, DisplayName = "FreeBSD EWOULDBLOCK")] + [DataRow("FREEBSD", 13, false, DisplayName = "FreeBSD EACCES")] + [DataRow(null, 5, true, DisplayName = "Unknown platform keeps every I/O failure as another instance")] + public void IsLockViolation_RecognisesEachPlatformsLockError(string? platformName, int code, bool expected) + { + // Arrange + OSPlatform? platform = platformName is null ? null : OSPlatform.Create(platformName); + + // Act + bool result = SingleAppInstance.IsLockViolation(code, platform); + + // Assert + Assert.AreEqual(expected, result); + } + [TestMethod] public void IsAlreadyRunning_WhenPidFileCannotBeRead_ShouldThrow() { diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index c742cbf..a9b17c5 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -229,20 +229,40 @@ internal static PidFileState ReadPidFileState() /// with the bare EWOULDBLOCK errno. On a Unix whose errno is not known here, every I/O failure /// is still taken to be another instance, as it was before access failures were told apart. /// - private static bool IsLockViolation(IOException exception) + private static bool IsLockViolation(IOException exception) => IsLockViolation(exception.HResult, CurrentPlatform); + + /// + /// FreeBSD, which names only on newer frameworks. + /// + private static readonly OSPlatform FreeBsd = OSPlatform.Create("FREEBSD"); + + /// + /// The platform this process runs on, or null when it is none whose lock error is known. + /// + private static readonly OSPlatform? CurrentPlatform = + new[] { OSPlatform.Windows, OSPlatform.Linux, OSPlatform.OSX, FreeBsd } + .Select(platform => (OSPlatform?)platform) + .FirstOrDefault(platform => RuntimeInformation.IsOSPlatform(platform!.Value)); + + /// + /// Checks whether an I/O error code is the one a platform reports when another process holds the file. + /// + /// The of the I/O failure. + /// The platform the failure happened on, or null when it is not known. + /// true for a sharing or lock violation; otherwise, false. + internal static bool IsLockViolation(int code, OSPlatform? platform) { - int code = exception.HResult; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (platform == OSPlatform.Windows) { return (code & 0xFFFF) is ErrorSharingViolation or ErrorLockViolation; } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + if (platform == OSPlatform.Linux) { return code == LinuxWouldBlock; } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || IsFreeBsd()) + if (platform == OSPlatform.OSX || platform == FreeBsd) { return code == BsdWouldBlock; } @@ -250,12 +270,6 @@ private static bool IsLockViolation(IOException exception) return true; } - /// - /// Checks whether the current platform is FreeBSD. - /// - /// true on FreeBSD; otherwise, false. - private static bool IsFreeBsd() => RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD")); - /// /// Describes a failure to use the PID file that does not come from another instance. ///