From 6f73241496e23d01c7fd0f8825bfbb8fe652cd64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:29:11 +0000 Subject: [PATCH 1/3] Compare the stored StartTime before treating a PID as another instance [patch] WritePidFile records the process start time, but nothing read it back. For an app run through the shared dotnet host, the process name and main module match every other dotnet process, so a stale PID recycled onto an MSBuild node or a dotnet tool blocked the app from launching. Both identity checks now also require the running process's start time to match the stored one within one second. PID files without a start time, and processes whose start time cannot be read, keep the previous check. Fixes #180 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 65 +++++++++++++++++++ SingleAppInstance/SingleAppInstance.cs | 48 +++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 871d8b9..525d9d6 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -291,6 +291,60 @@ 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 IsAlreadyRunning_WithHighNonExistentPid_ShouldReturnFalse() { @@ -491,6 +545,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. /// diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index f7803f2..a366f4f 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,8 @@ 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 +258,48 @@ 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) + { + if (storedProcess.StartTime == default) + { + return true; + } + + try + { + TimeSpan difference = runningProcess.StartTime.ToUniversalTime() - storedProcess.StartTime.ToUniversalTime(); + return difference.Duration() <= StartTimeTolerance; + } + catch (InvalidOperationException) + { + // Start time is not available for this process + return true; + } + catch (System.ComponentModel.Win32Exception) + { + // Access denied to the start time + return true; + } + } + /// /// Checks if the PID read from a legacy PID file belongs to another running instance of this application. /// From b02f057738d02dd0f334da47a04f9465dd7cbfc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:35:13 +0000 Subject: [PATCH 2/3] Wait for the helper's main module before describing it in tests On Windows a freshly started process reports no main module until the loader has finished, so the tests recorded a null module path and then compared it against the loaded one. The helper now waits until its main module is readable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index 525d9d6..dc4ad0d 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -666,7 +666,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() From f50377b74e21c17ef3b509b19172d34b8e17326c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:46:59 +0000 Subject: [PATCH 3/3] Unit-test the start-time comparison, including its fallbacks The fallbacks for an unreadable start time only run when the OS denies access, which no live process in the test suite triggers. The comparison now takes the running start time as a delegate so its tolerance, UTC round trip and fallbacks are tested directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018x3sGQTDEmvXP1cRdbmmkx --- .../SingleAppInstanceTests.cs | 41 +++++++++++++++++++ SingleAppInstance/SingleAppInstance.cs | 27 ++++++------ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/SingleAppInstance.Test/SingleAppInstanceTests.cs b/SingleAppInstance.Test/SingleAppInstanceTests.cs index dc4ad0d..2ee28f6 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -345,6 +345,47 @@ public void IsAlreadyRunning_WithMatchingProcessAndNoStoredStartTime_ShouldRetur 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() { diff --git a/SingleAppInstance/SingleAppInstance.cs b/SingleAppInstance/SingleAppInstance.cs index a366f4f..1c4f698 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -238,8 +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) && - HasStoredStartTime(process, storedProcess); + string.Equals(process.ProcessName, storedProcess.ProcessName, StringComparison.Ordinal) && HasStoredStartTime(process, storedProcess); } catch (ArgumentException) { @@ -276,26 +275,30 @@ private static bool IsStoredProcessRunningByName(ProcessInfo storedProcess) /// 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) + 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 (storedProcess.StartTime == default) + if (storedStartTime == default) { return true; } try { - TimeSpan difference = runningProcess.StartTime.ToUniversalTime() - storedProcess.StartTime.ToUniversalTime(); + TimeSpan difference = readRunningStartTime().ToUniversalTime() - storedStartTime.ToUniversalTime(); return difference.Duration() <= StartTimeTolerance; } - catch (InvalidOperationException) - { - // Start time is not available for this process - return true; - } - catch (System.ComponentModel.Win32Exception) + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - // Access denied to the start time + // The start time is unavailable or access to it is denied return true; } }