diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 871d8b9..2ee28f6 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -291,6 +291,101 @@ public void IsAlreadyRunning_WithRunningProcessButDifferentName_ShouldReturnFals } } + [TestMethod] + public void IsAlreadyRunning_WithMatchingProcessButDifferentStartTime_ShouldReturnFalse() + { + // Arrange - the PID, name and main module all describe a live process, but the start time + // does not, which is what a stale PID file looks like once the operating system recycles the + // PID onto another process run by the same host, such as another `dotnet` process + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("StartTimeHelper"); + + ProcessInfo processInfo = DescribeProcess(helper.Process); + processInfo.StartTime = processInfo.StartTime.AddHours(-1); + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo)); + + // Act + bool result = SingleAppInstance.IsAlreadyRunning(); + + // Assert + Assert.IsFalse(result, "Should return false when the running process started at a different time than the stored one"); + } + + [TestMethod] + public void IsAlreadyRunning_WithMatchingProcessAndStartTime_ShouldReturnTrue() + { + // Arrange - every stored field describes the live process, so it is another instance + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("StartTimeHelper"); + + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process))); + + // Act + bool result = SingleAppInstance.IsAlreadyRunning(); + + // Assert + Assert.IsTrue(result, "Should return true when the stored process information matches a running process"); + } + + [TestMethod] + public void IsAlreadyRunning_WithMatchingProcessAndNoStoredStartTime_ShouldReturnTrue() + { + // Arrange - a PID file written before the start time was recorded deserializes it as default + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("StartTimeHelper"); + + ProcessInfo processInfo = DescribeProcess(helper.Process); + processInfo.StartTime = default; + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(processInfo)); + + // Act + bool result = SingleAppInstance.IsAlreadyRunning(); + + // Assert + Assert.IsTrue(result, "Should fall back to the name and module check when no start time was stored"); + } + + [TestMethod] + public void HasStoredStartTime_WithinTolerance_ShouldReturnTrue() + { + DateTime stored = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(() => stored.AddMilliseconds(900), stored)); + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(() => stored.AddMilliseconds(-900), stored)); + } + + [TestMethod] + public void HasStoredStartTime_BeyondTolerance_ShouldReturnFalse() + { + DateTime stored = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + Assert.IsFalse(SingleAppInstance.HasStoredStartTime(() => stored.AddSeconds(2), stored)); + Assert.IsFalse(SingleAppInstance.HasStoredStartTime(() => stored.AddSeconds(-2), stored)); + } + + [TestMethod] + public void HasStoredStartTime_ComparesLocalAndUniversalTimesAsTheSameInstant() + { + DateTime stored = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(stored.ToLocalTime, stored)); + } + + [TestMethod] + public void HasStoredStartTime_WithNoStoredStartTime_ShouldNotReadTheRunningStartTime() + { + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(() => throw new AssertFailedException("The running start time should not be read"), default)); + } + + [TestMethod] + public void HasStoredStartTime_WhenRunningStartTimeIsUnavailable_ShouldReturnTrue() + { + DateTime stored = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(() => throw new InvalidOperationException(), stored)); + Assert.IsTrue(SingleAppInstance.HasStoredStartTime(() => throw new Win32Exception(), stored)); + } + [TestMethod] public void IsAlreadyRunning_WithHighNonExistentPid_ShouldReturnFalse() { @@ -491,6 +586,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. /// @@ -601,7 +707,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() diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index f7803f2..1c4f698 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -206,7 +206,8 @@ private static bool IsStoredProcessRunning(ProcessInfo storedProcess) return !runningProcess.HasExited && string.Equals(runningProcess.ProcessName, storedProcess.ProcessName, StringComparison.Ordinal) && runningProcess.MainModule != null && - string.Equals(runningProcess.MainModule.FileName, storedProcess.MainModuleFileName, StringComparison.OrdinalIgnoreCase); + string.Equals(runningProcess.MainModule.FileName, storedProcess.MainModuleFileName, StringComparison.OrdinalIgnoreCase) && + HasStoredStartTime(runningProcess, storedProcess); } catch (ArgumentException) { @@ -237,7 +238,7 @@ private static bool IsStoredProcessRunningByName(ProcessInfo storedProcess) using Process process = Process.GetProcessById(storedProcess.ProcessId); return !process.HasExited && - string.Equals(process.ProcessName, storedProcess.ProcessName, StringComparison.Ordinal); + string.Equals(process.ProcessName, storedProcess.ProcessName, StringComparison.Ordinal) && HasStoredStartTime(process, storedProcess); } catch (ArgumentException) { @@ -256,6 +257,52 @@ private static bool IsStoredProcessRunningByName(ProcessInfo storedProcess) } } + /// + /// How far apart the stored and observed start times of one process may be, allowing for the + /// precision lost in the JSON round trip and for platforms that derive start time from clock ticks. + /// + private static readonly TimeSpan StartTimeTolerance = TimeSpan.FromSeconds(1); + + /// + /// Checks whether a running process started when the PID file says the stored process did. + /// + /// The running process that currently holds the stored PID. + /// The process information read from the PID file. + /// true if the start times match or cannot be compared; otherwise, false. + /// + /// An application run through the shared dotnet host has the same process name and main module + /// as every other process that host runs, so a stale PID recycled onto any of them would otherwise pass + /// as another instance. The start time tells them apart. PID files written before the start time was + /// recorded, and processes whose start time cannot be read, keep the name and module check alone. + /// + private static bool HasStoredStartTime(Process runningProcess, ProcessInfo storedProcess) => + HasStoredStartTime(() => runningProcess.StartTime, storedProcess.StartTime); + + /// + /// Checks whether a running process's start time matches the stored one. + /// + /// Reads the running process's start time. + /// The start time read from the PID file. + /// true if the start times match or cannot be compared; otherwise, false. + internal static bool HasStoredStartTime(Func readRunningStartTime, DateTime storedStartTime) + { + if (storedStartTime == default) + { + return true; + } + + try + { + TimeSpan difference = readRunningStartTime().ToUniversalTime() - storedStartTime.ToUniversalTime(); + return difference.Duration() <= StartTimeTolerance; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // The start time is unavailable or access to it is denied + return true; + } + } + /// /// Checks if the PID read from a legacy PID file belongs to another running instance of this application. ///