diff --git a/README.md b/README.md index 7423678..ccaab7b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - **Single Instance Enforcement**: Prevents multiple copies of your application from running simultaneously - **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 +- **PID File Management**: Stores process information as JSON in the application data directory, replacing the file atomically so a racing instance never reads a partial write, and treating a PID file another instance is holding as that instance starting rather than throwing - **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 871d8b9..83f82b2 100644 --- a/SingleAppInstance.Test/SingleAppInstanceTests.cs +++ b/SingleAppInstance.Test/SingleAppInstanceTests.cs @@ -12,12 +12,19 @@ namespace ktsu.SingleAppInstance.Test; [DoNotParallelize] public class SingleAppInstanceTests { + public TestContext TestContext { get; set; } = null!; + [TestInitialize] public void TestInitialize() { // Ensure the PID directory exists and the file is deleted before each test string pidFilePath = SingleAppInstance.PidFilePath; Directory.CreateDirectory(SingleAppInstance.PidDirectoryPath); + if (Directory.Exists(pidFilePath)) + { + Directory.Delete(pidFilePath, recursive: true); + } + File.Delete(pidFilePath); } @@ -389,6 +396,235 @@ public void ShouldLaunch_WhenAlreadyRunning_ShouldReturnFalse() Assert.IsFalse(result, "ShouldLaunch should return false when another instance is detected"); } + [TestMethod] + public void IsAlreadyRunning_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnTrue() + { + // Arrange - two writers racing leave the shorter record followed by the tail of the longer + // one, and the record still describes the live instance that wrote it + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("TornPidHelper"); + + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}"); + + // Act + bool result = SingleAppInstance.IsAlreadyRunning(); + + // Assert + Assert.IsTrue(result, "A torn PID file whose leading record describes a running instance should read as that instance"); + } + + [TestMethod] + public void ShouldLaunch_WithGarbageSuffixedPidFileForRunningInstance_ShouldReturnFalse() + { + // Arrange + string pidFilePath = SingleAppInstance.PidFilePath; + using HelperProcess helper = HelperProcess.Start("TornPidHelper"); + + File.WriteAllText(pidFilePath, JsonSerializer.Serialize(DescribeProcess(helper.Process)) + "0\"}"); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + + // Assert + Assert.IsFalse(result, "A torn PID file must not let a second instance launch while the first is running"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFileBecomesUnreadableDuringRaceWindow_ShouldReturnFalse() + { + // Arrange - once this instance has written its PID file, a racing instance leaves content + // that cannot be parsed, which must count as that instance rather than as no instance + string pidFilePath = SingleAppInstance.PidFilePath; + string ownPid = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + + Task racingWriter = Task.Run(async () => + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(10)) + { + try + { + string content = await File.ReadAllTextAsync(pidFilePath, TestContext.CancellationToken).ConfigureAwait(false); + if (content.Contains(ownPid, StringComparison.Ordinal)) + { + await File.WriteAllTextAsync(pidFilePath, "{\"ProcessId\":12", TestContext.CancellationToken).ConfigureAwait(false); + return; + } + } + catch (IOException) + { + // Not written yet, or being replaced + } + catch (UnauthorizedAccessException) + { + // Being replaced + } + + await Task.Delay(10, TestContext.CancellationToken).ConfigureAwait(false); + } + }, TestContext.CancellationToken); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + racingWriter.Wait(TestContext.CancellationToken); + + // Assert + Assert.IsFalse(result, "Unparseable content after this instance wrote its PID file should not grant a launch"); + } + + [TestMethod] + public void ShouldLaunch_WhenPidFileIsHeldExclusively_ShouldReturnFalseWithoutThrowing() + { + // Arrange - another instance holding the PID file open is what a simultaneous launch looks like + string pidFilePath = SingleAppInstance.PidFilePath; + using FileStream heldPidFile = new(pidFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + // Act + bool result = SingleAppInstance.ShouldLaunch(); + + // Assert + Assert.IsFalse(result, "A PID file held by another instance should read as that instance starting"); + } + + [TestMethod] + public void WritePidFile_WhileBeingRead_ReaderNeverSeesAPartialFile() + { + // Arrange - a reader racing a writer must only ever see a whole PID file + string pidFilePath = SingleAppInstance.PidFilePath; + SingleAppInstance.WritePidFile(); + + using CancellationTokenSource writing = new(); + Task writer = Task.Run(() => + { + while (!writing.IsCancellationRequested) + { + try + { + SingleAppInstance.WritePidFile(); + } + catch (IOException) + { + // The reader held the file for every retry; contention is expected here + } + catch (UnauthorizedAccessException) + { + // The reader held the file for every retry; contention is expected here + } + } + }, TestContext.CancellationToken); + + int partialReads = 0; + string? lastPartialContent = null; + + // Act + try + { + for (int i = 0; i < 2000; i++) + { + string content; + try + { + content = File.ReadAllText(pidFilePath); + } + catch (IOException) + { + // The file was being replaced; a sharing violation is not a partial read + continue; + } + catch (UnauthorizedAccessException) + { + // The file was being replaced; a pending delete is not a partial read + continue; + } + + try + { + if (JsonSerializer.Deserialize(content) is null) + { + partialReads++; + lastPartialContent = content; + } + } + catch (JsonException) + { + partialReads++; + lastPartialContent = content; + } + } + } + finally + { + writing.Cancel(); + writer.Wait(TestContext.CancellationToken); + } + + // Assert + Assert.AreEqual(0, partialReads, $"Every read should see a whole PID file; last partial content was '{lastPartialContent}'"); + } + + [TestMethod] + public void WritePidFile_WhenPidFileCannotBeReplaced_ShouldThrowAndRemoveTemporaryFile() + { + // Arrange - a directory where the PID file belongs can never be replaced by a file + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + bool threw = false; + + // Act + try + { + SingleAppInstance.WritePidFile(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + threw = true; + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsTrue(threw, "WritePidFile should throw once every attempt to replace the PID file has failed"); + string[] temporaryFiles = Directory.GetFiles(SingleAppInstance.PidDirectoryPath, Path.GetFileName(pidFilePath) + ".*.tmp"); + Assert.IsEmpty(temporaryFiles, "The temporary file should be removed when the PID file cannot be replaced"); + } + + [TestMethod] + public void TryWritePidFile_WhenPidFileCannotBeReplaced_ShouldReturnFalse() + { + // Arrange + string pidFilePath = SingleAppInstance.PidFilePath; + Directory.CreateDirectory(pidFilePath); + + // Act + bool result; + try + { + result = SingleAppInstance.TryWritePidFile(); + } + finally + { + Directory.Delete(pidFilePath, recursive: true); + } + + // Assert + Assert.IsFalse(result, "TryWritePidFile should report that this instance could not claim the PID file"); + } + + [TestMethod] + public void TryWritePidFile_WhenPidFileCanBeWritten_ShouldReturnTrue() + { + // Act + bool result = SingleAppInstance.TryWritePidFile(); + + // Assert + Assert.IsTrue(result); + ProcessInfo? processInfo = JsonSerializer.Deserialize(File.ReadAllText(SingleAppInstance.PidFilePath)); + Assert.IsNotNull(processInfo); + Assert.AreEqual(Environment.ProcessId, processInfo.ProcessId); + } + [TestMethod] public void PidDirectoryPath_ShouldNotBeEmpty() { @@ -491,6 +727,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 +848,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..6aa4d40 100644 --- a/SingleAppInstance/SingleAppInstance.cs +++ b/SingleAppInstance/SingleAppInstance.cs @@ -4,6 +4,7 @@ namespace ktsu.SingleAppInstance; using System.Diagnostics; using System.Globalization; +using System.Text; using System.Text.Json; using ktsu.AppDataStorage; @@ -44,6 +45,8 @@ public static void ExitIfAlreadyRunning() /// If no other instance is running, it writes the current process ID to a PID file /// and waits for a short period to handle potential race conditions. It then checks /// again to ensure no other instance started during the wait period. + /// If the PID file stays locked by another process, or cannot be written, another instance + /// is taken to be starting and this method returns false rather than throwing. /// public static bool ShouldLaunch() { @@ -55,14 +58,51 @@ public static bool ShouldLaunch() // if no other instance is running, write our pid to the pid file and wait to see // if another instance was attempting to start at the same time - WritePidFile(); + if (!TryWritePidFile()) + { + return false; + } + Thread.Sleep(1000); // in case there was a race and another instance is starting at the same time we - // need to check again to see if we won the lock - return !IsAlreadyRunning(); + // need to check again to see if we won the lock. We just wrote a whole PID file, so + // content that cannot be read now was written by an instance racing us, and it counts + // as that instance rather than as no instance at all + return ReadPidFileState() == PidFileState.NoInstance; } + /// + /// What the PID file says about other instances of the application. + /// + internal enum PidFileState + { + /// + /// No other instance is running. + /// + NoInstance, + + /// + /// Another instance is running, or is holding the PID file while it starts. + /// + AnotherInstance, + + /// + /// The PID file exists but its contents cannot be understood. + /// + Unreadable, + } + + /// + /// How many times the PID file is read or replaced before contention is taken to be another instance. + /// + private const int PidFileAttempts = 5; + + /// + /// How long to wait before each retry, multiplied by the number of attempts made so far. + /// + private static readonly TimeSpan PidFileRetryDelay = TimeSpan.FromMilliseconds(20); + /// /// Represents process information stored in the PID file. /// @@ -99,29 +139,46 @@ internal class ProcessInfo /// This method reads the PID file to get the process information of the running instance. /// It then checks if the process with that ID is still running and verifies it's the same application. /// - internal static bool IsAlreadyRunning() + internal static bool IsAlreadyRunning() => ReadPidFileState() == PidFileState.AnotherInstance; + + /// + /// Reads the PID file and determines whether it describes another running instance. + /// + /// What the PID file says about other instances of the application. + /// + /// Another instance may be writing the PID file at the same moment, which on some platforms + /// makes the read fail with a sharing violation. The read is retried briefly, and if the file + /// stays inaccessible it is treated as another instance that is starting. + /// + internal static PidFileState ReadPidFileState() { int currentPid = GetCurrentProcessId(); - try - { - string pidFileContents = File.ReadAllText(PidFilePath); - return CheckPidFileContents(pidFileContents, currentPid); - } - catch (DirectoryNotFoundException) - { - // PID directory doesn't exist yet - no instance running - } - catch (FileNotFoundException) - { - // PID file doesn't exist - no instance running - } - catch (FormatException) + for (int attempt = 1; attempt <= PidFileAttempts; attempt++) { - // PID file content is corrupted - treat as no instance running + try + { + string pidFileContents = File.ReadAllText(PidFilePath); + return CheckPidFileContents(pidFileContents, currentPid); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // The PID file or its directory doesn't exist - no instance running + return PidFileState.NoInstance; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Another instance is writing or holding the PID file + } + + if (attempt < PidFileAttempts) + { + Thread.Sleep(TimeSpan.FromTicks(PidFileRetryDelay.Ticks * attempt)); + } } - return false; + // The PID file stayed inaccessible, so another instance is holding it while it starts + return PidFileState.AnotherInstance; } /// @@ -145,16 +202,22 @@ private static int GetCurrentProcessId() /// /// The raw contents of the PID file. /// The current process ID. - /// true if a different instance of the application is running; otherwise, false. - private static bool CheckPidFileContents(string pidFileContents, int currentPid) + /// What the contents say about other instances of the application. + /// + /// Only the first JSON value in the file is read. A PID file torn by two older writers racing + /// holds a complete record followed by the tail of a longer one, and that record still names the + /// instance that wrote it. + /// + private static PidFileState CheckPidFileContents(string pidFileContents, int currentPid) { ProcessInfo? storedProcess; try { - storedProcess = JsonSerializer.Deserialize(pidFileContents); + Utf8JsonReader reader = new(Encoding.UTF8.GetBytes(pidFileContents)); + storedProcess = JsonSerializer.Deserialize(ref reader); if (storedProcess == null) { - return false; + return PidFileState.NoInstance; } } catch (JsonException) @@ -164,31 +227,38 @@ private static bool CheckPidFileContents(string pidFileContents, int currentPid) if (storedProcess.ProcessId == currentPid) { - return false; + return PidFileState.NoInstance; } - return IsStoredProcessRunning(storedProcess); + return ToState(IsStoredProcessRunning(storedProcess)); } + /// + /// Converts the outcome of a process check into a PID file state. + /// + /// Whether another instance was found running. + /// The corresponding PID file state. + private static PidFileState ToState(bool isRunning) => isRunning ? PidFileState.AnotherInstance : PidFileState.NoInstance; + /// /// Handles backward-compatible legacy PID files that contain only a plain integer PID. /// /// The raw contents of the PID file. /// The current process ID. - /// true if the legacy PID corresponds to a running instance of this application; otherwise, false. - private static bool HandleLegacyPidFile(string pidFileContents, int currentPid) + /// What the legacy PID says about other instances of the application. + private static PidFileState HandleLegacyPidFile(string pidFileContents, int currentPid) { if (!int.TryParse(pidFileContents, NumberStyles.Integer, CultureInfo.InvariantCulture, out int filePid)) { - return false; + return PidFileState.Unreadable; } if (filePid == currentPid) { - return false; + return PidFileState.NoInstance; } - return IsLegacyProcessRunning(filePid); + return ToState(IsLegacyProcessRunning(filePid)); } /// @@ -333,7 +403,12 @@ private static bool IsSameApplicationName(string runningProcessName, string curr /// /// /// This method writes the current process information to the PID file in the application data path. + /// The record is written to a temporary file beside the PID file and then moved over it, so a reader + /// sees either the previous PID file or the new one and never a partial or interleaved write. + /// Replacing the file is retried briefly while another instance holds it. /// + /// The PID file stayed in use for every attempt. + /// The PID file could not be replaced. internal static void WritePidFile() { Directory.CreateDirectory(PidDirectoryPath); @@ -348,6 +423,80 @@ internal static void WritePidFile() }; string json = JsonSerializer.Serialize(processInfo); - File.WriteAllText(PidFilePath, json); + string pidFilePath = PidFilePath; + string temporaryPath = $"{pidFilePath}.{Guid.NewGuid():N}.tmp"; + + try + { + File.WriteAllText(temporaryPath, json); + + for (int attempt = 1; attempt < PidFileAttempts; attempt++) + { + try + { + ReplacePidFile(temporaryPath, pidFilePath); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Another instance is reading or replacing the PID file + } + + Thread.Sleep(TimeSpan.FromTicks(PidFileRetryDelay.Ticks * attempt)); + } + + // The final attempt lets a persistent failure reach the caller + ReplacePidFile(temporaryPath, pidFilePath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Writes the current process information to the PID file, reporting failure instead of throwing. + /// + /// true if the PID file now describes this process; otherwise, false. + /// + /// The PID file stays in use when another instance keeps it busy for the whole retry window, + /// and cannot be replaced at all when access is denied. Either way this instance cannot claim it. + /// + internal static bool TryWritePidFile() + { + try + { + WritePidFile(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } } + + /// + /// Moves a fully written temporary file over the PID file in a single operation. + /// + /// The fully written temporary file. + /// The PID file to create or replace. +#if NETCOREAPP3_0_OR_GREATER + private static void ReplacePidFile(string temporaryPath, string pidFilePath) => + File.Move(temporaryPath, pidFilePath, overwrite: true); +#else + private static void ReplacePidFile(string temporaryPath, string pidFilePath) + { + if (File.Exists(pidFilePath)) + { + File.Replace(temporaryPath, pidFilePath, destinationBackupFileName: null); + } + else + { + File.Move(temporaryPath, pidFilePath); + } + } +#endif }