using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text.Json; namespace CodexProviderSync.Core; /// /// Cross-platform single-instance guard. The first process to call /// owns the lock; subsequent callers get back a /// with IsOwner == false and /// the metadata of the existing owner so the caller can route a "focus" /// request to it. /// public sealed class SingleInstanceGuard { private const int Win32ErrorAlreadyExists = 183; private const int Win32ErrorAccessDenied = 5; private const int Win32ErrorSharingViolation = 32; private const int Win32ErrorLockViolation = 33; // The previous default budget (3 × 75 ms = 225 ms) was too tight for // Windows Defender / OneDrive / antivirus file-locking drivers which // can hold the directory open for several seconds on first launch. // 30 × 100 ms = 3 s gives the OS enough breathing room to release // the lock without making genuine failures drag on indefinitely. private const int DefaultCreateRetryCount = 30; private const int DefaultCreateRetryDelayMs = 100; private const int RaceWindowRetryCount = 20; private const int RaceWindowRetryDelayMs = 25; private static readonly JsonSerializerOptions OwnerJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; public SingleInstanceGuard() : this(StandardOwnerProbe) { } /// /// Test-only constructor: lets callers inject a probe for "is this PID /// still alive?" so unit tests do not depend on a real running process. /// internal SingleInstanceGuard(Func isProcessAlive) : this(isProcessAlive, DefaultLockDirectory()) { } /// /// Test-only constructor: lets callers inject the lock directory and the /// process probe. Use this to point the guard at a temp folder instead /// of the user's real %APPDATA%/codex-provider-sync/singleton. /// internal SingleInstanceGuard(Func isProcessAlive, string lockDirectory) { IsProcessAlive = isProcessAlive; LockDirectory = lockDirectory; } internal Func IsProcessAlive { get; } public string LockDirectory { get; } private static string DefaultLockDirectory() { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "codex-provider-sync", "singleton"); } public SingleInstanceAcquisition Acquire(string label = "codex-provider-sync") { // The lock directory itself is the contested resource, so it // must be created atomically below. Only its parent path // should be pre-created — using `Directory.CreateDirectory` // on the lock directory here would silently succeed on the // first launch, which then makes the atomic `TryCreateDirectory` // always report `Win32ErrorAlreadyExists` and route the first // caller into the stale-lock recovery branch. Two concurrent // launches could then race deleting each other's lock dir and // violate the single-instance guarantee. string? parentDirectory = Path.GetDirectoryName(LockDirectory); if (!string.IsNullOrEmpty(parentDirectory)) { Directory.CreateDirectory(parentDirectory); } int attempts = 0; while (true) { int errorCode = TryCreateDirectory(LockDirectory); if (errorCode == 0) { WriteOwnerMetadata(label); return new SingleInstanceAcquisition( isOwner: true, existingOwner: null, lockDirectory: LockDirectory, guard: this); } if (errorCode != Win32ErrorAlreadyExists) { if (!IsTransientLockCreateError(errorCode) || attempts >= DefaultCreateRetryCount) { throw new IOException( $"Unable to acquire single-instance lock at {LockDirectory}. Win32 error: {errorCode}"); } attempts += 1; System.Threading.Thread.Sleep(DefaultCreateRetryDelayMs); continue; } // Lock directory already exists. Inspect the owner and either // (a) refuse to start because the owner is still alive, or // (b) clean up and retry if the previous owner has died. SingleInstanceOwner? owner = ReadOwnerMetadata(); if (owner is not null && owner.ProcessId != Environment.ProcessId && IsProcessAlive(owner.ProcessId)) { return new SingleInstanceAcquisition( isOwner: false, existingOwner: owner, lockDirectory: LockDirectory, guard: this); } // If the lock dir exists but `owner.json` is missing // we have two possible cases: // (a) race window — the winning process atomically created // the lock dir but has not yet finished writing // `owner.json`. Do NOT delete the directory here; that // would race the winner's write and produce a // stale-lock IOException in both processes. Back off // briefly and let the winner finish. // (b) stale leftover — the previous owner died (or crashed) // and left behind arbitrary files (e.g., an unowned // `BLOCK` file from an interrupted cleanup). The lock // directory contains entries that are not `owner.json`, // so we can safely delete and reclaim. // We disambiguate by checking the lock dir's contents: an // empty lock dir signals the race window; a non-empty lock // dir with no `owner.json` signals stale leftover. We give // the race-window case a much larger retry budget (20 × 25 ms // = 500 ms) than the default because the winner's write of a // few-KB JSON file under anti-virus / filesystem contention // can easily exceed a couple hundred milliseconds on Windows. if (owner is null) { bool lockDirHasLeftovers = HasLockDirLeftovers(); if (!lockDirHasLeftovers) { attempts += 1; if (attempts >= RaceWindowRetryCount) { throw new IOException( $"Single-instance lock at {LockDirectory} exists but its owner metadata has not appeared yet. The owning process may be hung or under load."); } System.Threading.Thread.Sleep(RaceWindowRetryDelayMs); continue; } // Fall through to the stale-leftover delete branch. } // Stale lock (previous owner died or wrote no metadata). Best-effort // cleanup; if the delete loses a race, the next iteration will retry. try { Directory.Delete(LockDirectory, recursive: true); } catch (IOException) { attempts += 1; if (attempts >= DefaultCreateRetryCount) { throw new IOException( $"Single-instance lock at {LockDirectory} is held by a stale owner and could not be cleared. Remove the directory manually and retry."); } System.Threading.Thread.Sleep(DefaultCreateRetryDelayMs); } } } private void WriteOwnerMetadata(string label) { SingleInstanceOwner owner = new() { ProcessId = Environment.ProcessId, StartedAt = DateTimeOffset.UtcNow, Label = label, CurrentDirectory = Environment.CurrentDirectory }; File.WriteAllText( Path.Combine(LockDirectory, "owner.json"), JsonSerializer.Serialize(owner, OwnerJsonOptions)); } internal SingleInstanceOwner? ReadOwnerMetadata() { string path = Path.Combine(LockDirectory, "owner.json"); if (!File.Exists(path)) { return null; } try { string text = File.ReadAllText(path); return JsonSerializer.Deserialize(text, OwnerJsonOptions); } catch (Exception) { // Corrupt metadata; treat as no owner so the caller can decide. return null; } } internal static int TryCreateDirectory(string path) { return OperatingSystem.IsWindows() ? TryCreateDirectoryWindows(path) : TryCreateDirectoryPosix(path); } internal static int TryCreateDirectoryWindows(string path) { return CreateDirectory(path, IntPtr.Zero) ? 0 : Marshal.GetLastWin32Error(); } internal static int TryCreateDirectoryPosix(string path) { // 448 = 0700 octal; only the owner needs to read/write/execute the // singleton directory. if (PosixMkdir(path, 448) == 0) { return 0; } int errorCode = Marshal.GetLastWin32Error(); return errorCode switch { 17 => Win32ErrorAlreadyExists, // EEXIST 1 or 13 => Win32ErrorAccessDenied, // EPERM / EACCES _ => errorCode }; } [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CreateDirectory(string lpPathName, IntPtr lpSecurityAttributes); // Linux: glibc ships mkdir in libc. macOS: the same symbol resolves // through libSystem. Pin the EntryPoint and let the runtime pick the // right library on each platform via the DllImportResolver. Without // this, macOS hits an unresolved-symbol DllNotFoundException the // moment we call into this path. [DllImport("libc", EntryPoint = "mkdir", SetLastError = true)] private static extern int LibcMkdir(string pathname, uint mode); [DllImport("libSystem", EntryPoint = "mkdir", SetLastError = true)] private static extern int LibSystemMkdir(string pathname, uint mode); private static int PosixMkdir(string path, uint mode) { return OperatingSystem.IsMacOS() ? LibSystemMkdir(path, mode) : LibcMkdir(path, mode); } private static bool IsTransientLockCreateError(int errorCode) { // Access-denied, sharing-violation, and lock-violation are // all transient Windows errors that mean "the lock directory // is currently held by another process". Anti-virus and // cloud-sync drivers often hold these for a few seconds on // first launch; retrying through the default budget lets the // OS release them without surfacing an error to the user. return errorCode == Win32ErrorAccessDenied || errorCode == Win32ErrorSharingViolation || errorCode == Win32ErrorLockViolation; } // True when the lock directory exists, `owner.json` is not // present, and there is at least one other entry inside the // lock directory. Used to tell apart the "race window" // (lock dir is empty, winner is mid-write) from the // "stale leftover" (previous owner crashed and left // arbitrary files behind) cases without trusting timing. private bool HasLockDirLeftovers() { try { if (!Directory.Exists(LockDirectory)) { return false; } foreach (string entry in Directory.EnumerateFileSystemEntries(LockDirectory)) { string name = Path.GetFileName(entry); if (!string.Equals(name, "owner.json", StringComparison.Ordinal)) { return true; } } return false; } catch (IOException) { // Treat unreadable lock dirs as "leftovers" so the // caller can attempt the stale-delete branch rather // than spinning in the race-window backoff. return true; } catch (UnauthorizedAccessException) { return true; } } internal static bool StandardOwnerProbe(int processId) { if (processId <= 0) { return false; } try { using Process probe = Process.GetProcessById(processId); return !probe.HasExited; } catch (ArgumentException) { // No process with that id. return false; } catch (InvalidOperationException) { // The process has already exited by the time we touched it. return false; } catch (System.ComponentModel.Win32Exception) { // On Linux the owner may belong to another user; we cannot // query it, so treat it as "alive" to be safe and avoid // stealing the lock out from under another user. return true; } } } public sealed class SingleInstanceAcquisition : IDisposable { private readonly SingleInstanceGuard _guard; private bool _disposed; internal SingleInstanceAcquisition( bool isOwner, SingleInstanceOwner? existingOwner, string lockDirectory, SingleInstanceGuard guard) { IsOwner = isOwner; ExistingOwner = existingOwner; LockDirectory = lockDirectory; _guard = guard; } public bool IsOwner { get; } public SingleInstanceOwner? ExistingOwner { get; } public string LockDirectory { get; } public void Dispose() { if (_disposed) { return; } _disposed = true; if (IsOwner && Directory.Exists(LockDirectory)) { try { Directory.Delete(LockDirectory, recursive: true); } catch (IOException) { // Best-effort cleanup; the OS will release the directory on // process exit anyway. } } } } public sealed class SingleInstanceOwner { public int ProcessId { get; set; } public DateTimeOffset StartedAt { get; set; } public string Label { get; set; } = string.Empty; public string CurrentDirectory { get; set; } = string.Empty; }