Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion SingleAppInstance.Test/SingleAppInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -491,6 +586,17 @@ public void IsAlreadyRunning_WithJsonArrayInPidFile_ShouldReturnFalse()
Assert.IsFalse(result);
}

/// <summary>
/// Describes a running process the way <see cref="SingleAppInstance.WritePidFile"/> records one.
/// </summary>
private static ProcessInfo DescribeProcess(Process process) => new()
{
ProcessId = process.Id,
ProcessName = process.ProcessName,
StartTime = process.StartTime,
MainModuleFileName = process.MainModule?.FileName,
};

/// <summary>
/// Finds a running process that is neither the current process nor shares its process name.
/// </summary>
Expand Down Expand Up @@ -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;
}

/// <summary>
/// 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.
/// </summary>
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()
Expand Down
51 changes: 49 additions & 2 deletions SingleAppInstance/SingleAppInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
{
Expand All @@ -256,6 +257,52 @@ private static bool IsStoredProcessRunningByName(ProcessInfo storedProcess)
}
}

/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan StartTimeTolerance = TimeSpan.FromSeconds(1);

/// <summary>
/// Checks whether a running process started when the PID file says the stored process did.
/// </summary>
/// <param name="runningProcess">The running process that currently holds the stored PID.</param>
/// <param name="storedProcess">The process information read from the PID file.</param>
/// <returns><c>true</c> if the start times match or cannot be compared; otherwise, <c>false</c>.</returns>
/// <remarks>
/// An application run through the shared <c>dotnet</c> 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.
/// </remarks>
private static bool HasStoredStartTime(Process runningProcess, ProcessInfo storedProcess) =>
HasStoredStartTime(() => runningProcess.StartTime, storedProcess.StartTime);

/// <summary>
/// Checks whether a running process's start time matches the stored one.
/// </summary>
/// <param name="readRunningStartTime">Reads the running process's start time.</param>
/// <param name="storedStartTime">The start time read from the PID file.</param>
/// <returns><c>true</c> if the start times match or cannot be compared; otherwise, <c>false</c>.</returns>
internal static bool HasStoredStartTime(Func<DateTime> 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;
}
}

/// <summary>
/// Checks if the PID read from a legacy PID file belongs to another running instance of this application.
/// </summary>
Expand Down
Loading