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); } ///