diff --git a/README.md b/README.md
index bb93b07..7423678 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
- **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
-- **Backward Compatibility**: Gracefully handles legacy PID files that stored only a plain integer PID
+- **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 fd14664..871d8b9 100644
--- a/SingleAppInstance.Test/SingleAppInstanceTests.cs
+++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs
@@ -2,8 +2,10 @@
namespace ktsu.SingleAppInstance.Test;
+using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Text.Json;
[TestClass]
@@ -140,43 +142,40 @@ public void IsAlreadyRunning_WithLegacyPidFile_NonExistentProcess_ShouldReturnFa
}
[TestMethod]
- public void IsAlreadyRunning_WithLegacyPidFile_RunningProcess_ShouldReturnTrue()
+ public void IsAlreadyRunning_WithLegacyPidFile_RunningProcessWithDifferentName_ShouldReturnFalse()
{
- // Arrange - legacy PID file with a PID of a process that IS running
- // Use a well-known process that should always be running
+ // Arrange - legacy PID file holding the PID of an unrelated running process, which is what a
+ // stale legacy PID file looks like once the operating system has recycled the PID
string pidFilePath = SingleAppInstance.PidFilePath;
using Process currentProcess = Process.GetCurrentProcess();
- Process? targetProcess = null;
+ using Process? targetProcess = FindRunningProcessWithDifferentName(currentProcess);
- try
- {
- // Find a different running process to use
- foreach (Process p in Process.GetProcesses())
- {
- if (p.Id != currentProcess.Id)
- {
- targetProcess = p;
- break;
- }
- else
- {
- p.Dispose();
- }
- }
+ Assert.IsNotNull(targetProcess, "Should find at least one other running process with a different name");
+ File.WriteAllText(pidFilePath, targetProcess.Id.ToString(CultureInfo.InvariantCulture));
- Assert.IsNotNull(targetProcess, "Should find at least one other running process");
- File.WriteAllText(pidFilePath, targetProcess.Id.ToString(CultureInfo.InvariantCulture));
+ // Act
+ bool result = SingleAppInstance.IsAlreadyRunning();
- // Act
- bool result = SingleAppInstance.IsAlreadyRunning();
+ // Assert
+ Assert.IsFalse(result, "Should return false when the recycled PID belongs to a process that is not this application");
+ }
- // Assert
- Assert.IsTrue(result, "Should return true for a running process in legacy format");
- }
- finally
- {
- targetProcess?.Dispose();
- }
+ [TestMethod]
+ public void IsAlreadyRunning_WithLegacyPidFile_RunningProcessWithSameName_ShouldReturnTrue()
+ {
+ // Arrange - legacy PID file holding the PID of a running process that carries this
+ // application's own process name, which is as close to another instance as the legacy
+ // format can describe
+ string pidFilePath = SingleAppInstance.PidFilePath;
+ using HelperProcess helper = StartInstanceLookalikeProcess();
+
+ File.WriteAllText(pidFilePath, helper.Process.Id.ToString(CultureInfo.InvariantCulture));
+
+ // Act
+ bool result = SingleAppInstance.IsAlreadyRunning();
+
+ // Assert
+ Assert.IsTrue(result, "Should return true for a running process that matches this application's name in legacy format");
}
[TestMethod]
@@ -376,42 +375,18 @@ public void ShouldLaunch_WithNoExistingInstance_ShouldReturnTrue()
[TestMethod]
public void ShouldLaunch_WhenAlreadyRunning_ShouldReturnFalse()
{
- // Arrange - Write a PID file for a different running process using legacy format
- // This ensures IsAlreadyRunning() returns true on the first call
+ // Arrange - a running process carrying this application's name, recorded in the legacy
+ // format, is what another instance looks like to IsAlreadyRunning()
string pidFilePath = SingleAppInstance.PidFilePath;
- Process? targetProcess = null;
+ using HelperProcess helper = StartInstanceLookalikeProcess();
- try
- {
- // Find a different running process
- foreach (Process p in Process.GetProcesses())
- {
- if (p.Id != Environment.ProcessId)
- {
- targetProcess = p;
- break;
- }
- else
- {
- p.Dispose();
- }
- }
-
- Assert.IsNotNull(targetProcess, "Should find at least one other running process");
+ File.WriteAllText(pidFilePath, helper.Process.Id.ToString(CultureInfo.InvariantCulture));
- // Write legacy format PID file so IsAlreadyRunning returns true
- File.WriteAllText(pidFilePath, targetProcess.Id.ToString(CultureInfo.InvariantCulture));
-
- // Act
- bool result = SingleAppInstance.ShouldLaunch();
+ // Act
+ bool result = SingleAppInstance.ShouldLaunch();
- // Assert
- Assert.IsFalse(result, "ShouldLaunch should return false when another instance is detected");
- }
- finally
- {
- targetProcess?.Dispose();
- }
+ // Assert
+ Assert.IsFalse(result, "ShouldLaunch should return false when another instance is detected");
}
[TestMethod]
@@ -516,6 +491,155 @@ public void IsAlreadyRunning_WithJsonArrayInPidFile_ShouldReturnFalse()
Assert.IsFalse(result);
}
+ ///
+ /// Finds a running process that is neither the current process nor shares its process name.
+ ///
+ private static Process? FindRunningProcessWithDifferentName(Process currentProcess)
+ {
+ foreach (Process candidate in Process.GetProcesses())
+ {
+ bool isUsable = false;
+
+ try
+ {
+ // A name that prefixes the current process's name is excluded too, because a
+ // platform that truncates process names reports this application that way
+ isUsable = candidate.Id != currentProcess.Id &&
+ !currentProcess.ProcessName.StartsWith(candidate.ProcessName, StringComparison.Ordinal);
+ }
+ catch (InvalidOperationException)
+ {
+ // Process exited between enumeration and inspection
+ }
+ catch (Win32Exception)
+ {
+ // Process details are not accessible
+ }
+
+ if (isUsable)
+ {
+ return candidate;
+ }
+
+ candidate.Dispose();
+ }
+
+ return null;
+ }
+
+ ///
+ /// Launches a long-lived process that reports this application's own process name, so it is
+ /// indistinguishable from another instance to a PID file that records nothing but a PID.
+ ///
+ private static HelperProcess StartInstanceLookalikeProcess()
+ {
+ using Process currentProcess = Process.GetCurrentProcess();
+ string currentProcessName = currentProcess.ProcessName;
+
+ HelperProcess helper = HelperProcess.Start(currentProcessName);
+ string helperProcessName = helper.Process.ProcessName;
+
+ // Some platforms report another process's name truncated, so a prefix of the current
+ // process's name is as close a lookalike as the platform allows
+ bool isLookalike = string.Equals(helperProcessName, currentProcessName, StringComparison.Ordinal) ||
+ (helperProcessName.Length < currentProcessName.Length &&
+ currentProcessName.StartsWith(helperProcessName, StringComparison.Ordinal));
+
+ if (!isLookalike)
+ {
+ helper.Dispose();
+ Assert.Inconclusive($"Could not launch a helper process named '{currentProcessName}' on this platform; it reported '{helperProcessName}'");
+ }
+
+ return helper;
+ }
+
+ ///
+ /// A long-lived child process used to stand in for another instance of the application.
+ ///
+ ///
+ /// A long-running system executable is copied to a temporary file named after the requested
+ /// process name, so the child reports that process name to the operating system.
+ ///
+ private sealed class HelperProcess : IDisposable
+ {
+ private readonly string temporaryDirectory;
+
+ public Process Process { get; }
+
+ private HelperProcess(Process process, string temporaryDirectory)
+ {
+ Process = process;
+ this.temporaryDirectory = temporaryDirectory;
+ }
+
+ public static HelperProcess Start(string processName)
+ {
+ bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ string systemExecutable = isWindows
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ping.exe")
+ : "/bin/sleep";
+ string arguments = isWindows ? "-n 120 127.0.0.1" : "120";
+
+ string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"{nameof(SingleAppInstanceTests)}_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(temporaryDirectory);
+
+ string executable = Path.Combine(temporaryDirectory, isWindows ? $"{processName}.exe" : processName);
+ File.Copy(systemExecutable, executable);
+
+ if (!isWindows)
+ {
+ File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ ProcessStartInfo startInfo = new(executable, arguments)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ Process? process = Process.Start(startInfo);
+ Assert.IsNotNull(process, "Should be able to start a helper process");
+
+ return new HelperProcess(process, temporaryDirectory);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (!Process.HasExited)
+ {
+ Process.Kill();
+ Process.WaitForExit(5000);
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // Process already exited
+ }
+ catch (Win32Exception)
+ {
+ // Process could not be signalled
+ }
+
+ Process.Dispose();
+
+ try
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Temporary files are left for the operating system to reclaim
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Temporary files are left for the operating system to reclaim
+ }
+ }
+ }
+
// This class needs to mirror the internal ProcessInfo class for testing
private sealed class ProcessInfo
{
diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs
index b036d2e..f7803f2 100644
--- a/SingleAppInstance/SingleAppInstance.cs
+++ b/SingleAppInstance/SingleAppInstance.cs
@@ -175,7 +175,7 @@ private static bool CheckPidFileContents(string pidFileContents, int currentPid)
///
/// The raw contents of the PID file.
/// The current process ID.
- /// true if the legacy PID corresponds to a running process; otherwise, false.
+ /// true if the legacy PID corresponds to a running instance of this application; otherwise, false.
private static bool HandleLegacyPidFile(string pidFileContents, int currentPid)
{
if (!int.TryParse(pidFileContents, NumberStyles.Integer, CultureInfo.InvariantCulture, out int filePid))
@@ -188,7 +188,7 @@ private static bool HandleLegacyPidFile(string pidFileContents, int currentPid)
return false;
}
- return IsProcessRunning(filePid);
+ return IsLegacyProcessRunning(filePid);
}
///
@@ -257,24 +257,75 @@ private static bool IsStoredProcessRunningByName(ProcessInfo storedProcess)
}
///
- /// Checks if a process with the given PID is currently running.
+ /// Checks if the PID read from a legacy PID file belongs to another running instance of this application.
///
- /// The process ID to check.
- /// true if a process with the given PID is running; otherwise, false.
- private static bool IsProcessRunning(int pid)
+ /// The process ID read from the legacy PID file.
+ /// true if the PID belongs to a running process with the same name as the current process; otherwise, false.
+ ///
+ /// The legacy format stores nothing but the PID, so a stale file left behind by an abnormal exit
+ /// will match whatever unrelated process the operating system later recycles that PID onto. The
+ /// running process's name is therefore compared against the current process's name before it is
+ /// treated as another instance, mirroring the identity confirmation the JSON format performs.
+ /// When identity cannot be confirmed the PID is treated as stale so the application still launches.
+ ///
+ private static bool IsLegacyProcessRunning(int pid)
{
- Process[] processes = Process.GetProcesses();
+ string currentProcessName;
+ using (Process currentProcess = Process.GetCurrentProcess())
+ {
+ currentProcessName = currentProcess.ProcessName;
+ }
+
try
{
- return Array.Exists(processes, p => p.Id == pid);
+ using Process runningProcess = Process.GetProcessById(pid);
+
+ return !runningProcess.HasExited &&
+ IsSameApplicationName(runningProcess.ProcessName, currentProcessName);
}
- finally
+ catch (ArgumentException)
{
- foreach (Process p in processes)
- {
- p.Dispose();
- }
+ // Process not found - no longer running
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ // Process has exited
+ return false;
}
+ catch (System.ComponentModel.Win32Exception)
+ {
+ // Access denied to process details - identity cannot be confirmed
+ return false;
+ }
+ }
+
+ ///
+ /// The longest process name some platforms report when asked about a process other than the current one.
+ ///
+ /// Linux stores at most 15 characters of a process name, so longer names are read back truncated.
+ private const int TruncatedProcessNameLength = 15;
+
+ ///
+ /// Compares the name of another running process against the current process's own name.
+ ///
+ /// The name reported for the other running process.
+ /// The name reported for the current process.
+ /// true if both names describe the same application; otherwise, false.
+ ///
+ /// The current process reports its full name while another process's name can come back truncated,
+ /// so a name truncated at exactly the platform limit is accepted when it prefixes the current name.
+ ///
+ private static bool IsSameApplicationName(string runningProcessName, string currentProcessName)
+ {
+ if (string.Equals(runningProcessName, currentProcessName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return runningProcessName.Length == TruncatedProcessNameLength &&
+ currentProcessName.Length > TruncatedProcessNameLength &&
+ currentProcessName.StartsWith(runningProcessName, StringComparison.Ordinal);
}
///