Files
2026-07-13 12:31:29 +08:00

269 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CodexProviderSync.Core.Tests;
public sealed class SingleInstanceGuardTests
{
private static (string lockDir, string settingsRoot) NewIsolatedLockDir()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
string lockDir = Path.Combine(root, "singleton");
return (lockDir, root);
}
private static void Cleanup(string settingsRoot)
{
try
{
if (Directory.Exists(settingsRoot))
{
Directory.Delete(settingsRoot, recursive: true);
}
}
catch
{
// best-effort
}
}
[Fact]
public void Acquire_FirstCallerIsOwner_AndLockDirectoryExists()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
SingleInstanceGuard guard = new(_ => false, lockDir);
using SingleInstanceAcquisition acquisition = guard.Acquire("test");
Assert.True(acquisition.IsOwner);
Assert.Null(acquisition.ExistingOwner);
Assert.Equal(lockDir, guard.LockDirectory);
Assert.True(Directory.Exists(lockDir));
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_SecondCallerSeesExistingOwner_WhenFirstIsAlive()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First call takes the lock. We then mutate owner.json to
// advertise a different (synthetic) pid so the test simulates a
// different process holding the lock — within a single test
// process we cannot Acquire twice as owner (the guard's
// stale-recovery path would treat the first as a self-stale).
SingleInstanceGuard first = new(_ => true, lockDir);
using SingleInstanceAcquisition firstAcq = first.Acquire("first");
Assert.True(firstAcq.IsOwner);
string ownerPath = Path.Combine(lockDir, "owner.json");
string ownerJson = File.ReadAllText(ownerPath);
File.WriteAllText(ownerPath, ownerJson.Replace(
$"\"processId\": {Environment.ProcessId}",
"\"processId\": 99999"));
SingleInstanceGuard second = new(_ => true, lockDir);
using SingleInstanceAcquisition secondAcq = second.Acquire("second");
Assert.False(secondAcq.IsOwner);
Assert.NotNull(secondAcq.ExistingOwner);
Assert.Equal(99999, secondAcq.ExistingOwner!.ProcessId);
Assert.Equal("first", secondAcq.ExistingOwner.Label);
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_RecoversFromStaleLock_WhenPreviousOwnerIsDead()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First acquisition is "abandoned" without being disposed.
SingleInstanceGuard stale = new(_ => true, lockDir);
SingleInstanceAcquisition staleAcq = stale.Acquire("stale");
Assert.True(staleAcq.IsOwner);
// Pretend the stale owner died by leaving the dir on disk.
staleAcq.Dispose();
// New acquisition must observe a dead owner (probe returns false) and
// recover by taking the lock itself.
SingleInstanceGuard fresh = new(_ => false, lockDir);
using SingleInstanceAcquisition freshAcq = fresh.Acquire("fresh");
Assert.True(freshAcq.IsOwner);
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Dispose_RemovesLockDirectoryForOwner()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
SingleInstanceGuard guard = new(_ => false, lockDir);
using (guard.Acquire("owner"))
{
Assert.True(Directory.Exists(lockDir));
}
Assert.False(Directory.Exists(lockDir));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_RecoversWhenPreviousLockContainsLeftoverFile()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First call takes the lock with a live probe, then we abandon it
// (Dispose). Dispose() removes the lock directory, so we recreate
// it manually and drop a stray file inside to simulate a partial
// cleanup. The second call has the probe say "dead", so the guard
// has to clear the entire tree (including the stray file) and
// reclaim the lock.
SingleInstanceGuard first = new(_ => true, lockDir);
SingleInstanceAcquisition firstAcq = first.Acquire("first");
firstAcq.Dispose();
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "BLOCK"), "leftover");
SingleInstanceGuard second = new(_ => false, lockDir);
using SingleInstanceAcquisition secondAcq = second.Acquire("second");
Assert.True(secondAcq.IsOwner);
Assert.False(File.Exists(Path.Combine(lockDir, "BLOCK")));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_FirstCallOnSharedParentDirCreatesLockDirAtomically_AndSecondCallSeesExistingOwner()
{
// Regression guard: previously `Acquire` called
// `Directory.CreateDirectory(LockDirectory)` first, which
// recursively creates the lock dir on the very first
// launch. The follow-up atomic `TryCreateDirectory(LockDirectory)`
// then always returned `Win32ErrorAlreadyExists` and
// routed the first caller into the stale-lock recovery
// branch. With the fix, only the parent directory is
// pre-created; the lock dir itself is created atomically
// by `TryCreateDirectory`, so the first launch takes the
// owner branch directly without touching the recovery
// path. We verify both halves of that contract here:
//
// 1. The very first call writes `owner.json` (i.e.
// took the atomic-create success branch, not the
// stale-recovery branch).
// 2. A second call, when the lock dir already exists
// and owner.json names a live pid, returns
// `IsOwner == false` instead of clearing the lock
// and reclaiming it.
//
// We cannot use two callers in the same test process
// because `Environment.ProcessId` is constant for the
// whole process — a second in-process `Acquire` would
// see its own pid in the just-written `owner.json` and
// fall into the stale-recovery branch (a Windows pid
// recycle scenario, not a real concurrent-launch race).
// Instead, we seed `owner.json` with a fictitious live
// pid and let the second caller observe it. This is
// exactly what a concurrent launch from another process
// looks like at the filesystem level.
string settingsRoot = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(settingsRoot);
string lockDir = Path.Combine(settingsRoot, "singleton");
try
{
// First caller: lockDir does not exist yet, the
// atomic `TryCreateDirectory` must succeed and we
// must take the owner branch.
SingleInstanceGuard firstGuard = new(static _ => true, lockDir);
using SingleInstanceAcquisition firstAcq = firstGuard.Acquire("first");
Assert.True(firstAcq.IsOwner);
Assert.Null(firstAcq.ExistingOwner);
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")),
"first caller must take the atomic-create success branch and write owner.json directly");
firstAcq.Dispose();
// Seed `owner.json` with a fictitious live pid so
// the second caller observes the existing-owner
// branch instead of the pid-recycle stale branch.
// We use `pid = 1` (system init / PID 1 is always
// alive on Windows/Linux/macOS for the duration of
// any test run) and a probe that returns true for
// any pid.
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "owner.json"),
"{\"processId\":1,\"startedAt\":\"2026-01-01T00:00:00+00:00\",\"label\":\"existing\",\"currentDirectory\":\"C:\\\\\"}");
SingleInstanceGuard secondGuard = new(static _ => true, lockDir);
using SingleInstanceAcquisition secondAcq = secondGuard.Acquire("second");
Assert.False(secondAcq.IsOwner);
Assert.NotNull(secondAcq.ExistingOwner);
Assert.Equal(1, secondAcq.ExistingOwner!.ProcessId);
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")),
"second caller must not delete the lock directory when the owner is still alive");
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_PidRecycleInOwnerJson_TreatedAsStaleAndReclaimed()
{
// Companion regression guard: when `owner.json`
// contains a pid that matches the current process but
// the process did NOT actually take the lock (e.g.,
// Windows reused our pid for a brand-new process while
// the previous owner crashed mid-write), the guard
// must treat that as stale and reclaim the lock. The
// signal we use is: the lock dir contains an
// `owner.json` with our pid but no leftover file from
// a partial write, and a probe that says the pid is
// dead. We hand-craft that scenario here.
string settingsRoot = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(settingsRoot);
string lockDir = Path.Combine(settingsRoot, "singleton");
try
{
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "owner.json"),
$"{{\"processId\":{Environment.ProcessId},\"startedAt\":\"2026-01-01T00:00:00+00:00\",\"label\":\"dead-recycle\",\"currentDirectory\":\"C:\\\\\"}}");
SingleInstanceGuard guard = new(static _ => false, lockDir);
using SingleInstanceAcquisition acq = guard.Acquire("recycled");
Assert.True(acq.IsOwner);
Assert.Null(acq.ExistingOwner);
}
finally
{
Cleanup(settingsRoot);
}
}
}