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..6f0095a 100644
--- a/SingleAppInstance.Test/SingleAppInstanceTests.cs
+++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs
@@ -686,17 +686,72 @@ 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]
+ [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()
+ {
+ // Arrange - reading a directory fails with access denied on every platform, and keeps failing
string pidFilePath = SingleAppInstance.PidFilePath;
Directory.CreateDirectory(pidFilePath);
// Act
- bool result;
+ Exception? failure = null;
try
{
- result = SingleAppInstance.TryWritePidFile();
+ SingleAppInstance.IsAlreadyRunning();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ failure = ex;
}
finally
{
@@ -704,7 +759,69 @@ public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldReturnFalse()
}
// Assert
- Assert.IsFalse(result, "TryWritePidFile should report that this instance could not claim the PID file");
+ 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 = null;
+ bool threw = false;
+ try
+ {
+ result = SingleAppInstance.ShouldLaunch();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ threw = true;
+ }
+ finally
+ {
+ Directory.Delete(pidFilePath, recursive: true);
+ }
+
+ // Assert
+ 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..a9b17c5 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,102 @@ 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) => 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)
+ {
+ if (platform == OSPlatform.Windows)
+ {
+ return (code & 0xFFFF) is ErrorSharingViolation or ErrorLockViolation;
+ }
+
+ if (platform == OSPlatform.Linux)
+ {
+ return code == LinuxWouldBlock;
+ }
+
+ if (platform == OSPlatform.OSX || platform == FreeBsd)
+ {
+ return code == BsdWouldBlock;
+ }
+
+ return true;
+ }
+
+ ///
+ /// 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 +615,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 +628,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);
+ }
}
///