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.
///