From ebabf790173601cc19a9c7ea083c9da1d58c2bfe Mon Sep 17 00:00:00 2001 From: Miheichev Aleksandr Sergeevich Date: Mon, 2 Nov 2026 01:20:08 +0000 Subject: [PATCH] fix(webapi): allow reading the instance-lock owner on macOS (#10454) * test(webapi): cover instance-lock owner reads across processes * fix(webapi): keep macOS instance-lock owners readable * test(webapi): wait for lock release after owner exit --- .github/workflows/build-WebAPI.yml | 18 ++ .../v2rayN.WebAPI/Launcher/WebInstanceLock.cs | 7 +- .../InstanceLockProcessHelper.csproj | 12 ++ .../Tests/ProcessHelper/Program.cs | 31 +++ .../Tests/WebInstanceLockProcessTests.cs | 182 ++++++++++++++++++ .../Tests/v2rayN.WebAPI.Tests.csproj | 7 + 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/InstanceLockProcessHelper.csproj create mode 100644 v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/Program.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebInstanceLockProcessTests.cs diff --git a/.github/workflows/build-WebAPI.yml b/.github/workflows/build-WebAPI.yml index 0fcd0491..119ea1b7 100644 --- a/.github/workflows/build-WebAPI.yml +++ b/.github/workflows/build-WebAPI.yml @@ -46,6 +46,24 @@ permissions: contents: read jobs: + macos: + name: macOS WebAPI and ServiceLib tests + runs-on: macos-latest + timeout-minutes: 15 + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - name: Run ServiceLib.Tests + run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj -c Release + - name: Run WebAPI.Tests + run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj -c Release + identity: name: Resolve API build identity runs-on: ubuntu-24.04 diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs index cbf058e4..eaed7fa0 100644 --- a/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs @@ -88,9 +88,11 @@ public sealed class WebInstanceLock : IDisposable try { string ownerText; - if (OperatingSystem.IsLinux()) + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - var descriptor = Open(Path.GetFullPath(path), OpenReadOnly | OpenCloseOnExec, 0); + // A path-based FileStream read requests a shared flock that conflicts with the owner's lock. + var closeOnExec = OperatingSystem.IsMacOS() ? OpenCloseOnExecMacOS : OpenCloseOnExec; + var descriptor = Open(Path.GetFullPath(path), OpenReadOnly | closeOnExec, 0); if (descriptor < 0) { return null; @@ -163,6 +165,7 @@ public sealed class WebInstanceLock : IDisposable private const int OpenReadWrite = 2; private const int OpenCreate = 64; private const int OpenCloseOnExec = 524288; + private const int OpenCloseOnExecMacOS = 0x01000000; private const uint UserReadWriteMode = 0x180; private const long WindowsLockOffset = 1024 * 1024; private const long WindowsLockLength = 1; diff --git a/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/InstanceLockProcessHelper.csproj b/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/InstanceLockProcessHelper.csproj new file mode 100644 index 00000000..ab8c96c1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/InstanceLockProcessHelper.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + false + false + false + + + + + diff --git a/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/Program.cs b/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/Program.cs new file mode 100644 index 00000000..dc2ef113 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ProcessHelper/Program.cs @@ -0,0 +1,31 @@ +using v2rayN.WebAPI.Launcher; + +if (args.Length != 2) return 64; +var mode = args[0]; +var path = args[1]; +if (mode == "read-owner") +{ + Console.WriteLine($"owner:{WebInstanceLock.ReadOwnerProcessId(path)}"); + return 0; +} +if (mode is not ("hold" or "try-acquire")) return 64; +if (!WebInstanceLock.TryAcquire(path, writeOwner: true, out var instanceLock)) +{ + Console.WriteLine("busy"); + return 2; +} +using (instanceLock) +{ + if (mode == "try-acquire") + { + Console.WriteLine("acquired"); + return 0; + } + Console.WriteLine($"locked:{Environment.ProcessId}"); + while (await Console.In.ReadLineAsync() is { } command && command != "release") + { + if (command != "read-owner") return 64; + Console.WriteLine($"owner:{WebInstanceLock.ReadOwnerProcessId(path)}"); + } +} +return 0; diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebInstanceLockProcessTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebInstanceLockProcessTests.cs new file mode 100644 index 00000000..67f63df0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebInstanceLockProcessTests.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class WebInstanceLockProcessTests +{ + [Test] + public async Task OwnerReadsAcrossProcessesAndInsideTheOwnerPreserveExclusiveOwnership() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "owner file-å-锁.lock"); + await using var owner = LockProcess.Start("hold", path); + await (await owner.ReadLineAsync()).Should().BeEqualTo($"locked:{owner.Id}"); + await AssertAcquisition(path, acquired: false); + + await using (var reader = LockProcess.Start("read-owner", path)) + { + await (await reader.ReadLineAsync()).Should().BeEqualTo($"owner:{owner.Id}"); + await (await reader.WaitForExitAsync()).Should().BeEqualTo(0); + } + for (var i = 0; i < 10; i++) + { + await WebInstanceLock.ReadOwnerProcessId(path).Should().BeEqualTo(owner.Id); + } + await AssertAcquisition(path, acquired: false); + + await owner.SendAsync("read-owner"); + await (await owner.ReadLineAsync()).Should().BeEqualTo($"owner:{owner.Id}"); + await AssertAcquisition(path, acquired: false); + await WebInstanceLock.ReadOwnerProcessId(path).Should().BeEqualTo(owner.Id); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task LockCanBeReacquiredAfterOwnerExit(bool terminateOwner) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "instance.lock"); + await using var owner = LockProcess.Start("hold", path); + await (await owner.ReadLineAsync()).Should().BeEqualTo($"locked:{owner.Id}"); + await AssertAcquisition(path, acquired: false); + if (terminateOwner) + { + await owner.KillAsync(); + } + else + { + await owner.SendAsync("release"); + await (await owner.WaitForExitAsync()).Should().BeEqualTo(0); + } + + // Windows can report process exit before releasing the owner's byte-range lock. + var releaseWait = Stopwatch.StartNew(); + while (WebInstanceLock.IsHeld(path) && releaseWait.Elapsed < TimeSpan.FromSeconds(20)) + { + await Task.Delay(10); + } + + await WebInstanceLock.IsHeld(path).Should().BeFalse(); + await AssertAcquisition(path, acquired: true); + await using var replacement = LockProcess.Start("hold", path); + await (await replacement.ReadLineAsync()).Should().BeEqualTo($"locked:{replacement.Id}"); + await WebInstanceLock.ReadOwnerProcessId(path).Should().BeEqualTo(replacement.Id); + await AssertAcquisition(path, acquired: false); + } + + [Test] + public async Task ReadingAMissingOwnerDoesNotCreateALockFile() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "missing.lock"); + await WebInstanceLock.ReadOwnerProcessId(path).Should().BeNull(); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + [Arguments("not-a-pid")] + [Arguments("0")] + [Arguments("-1")] + [Arguments("2147483648")] + public async Task ReadingAnInvalidOwnerReturnsNoProcessId(string contents) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "instance.lock"); + await File.WriteAllTextAsync(path, contents); + await WebInstanceLock.ReadOwnerProcessId(path).Should().BeNull(); + } + + private static async Task AssertAcquisition(string path, bool acquired) + { + await using var contender = LockProcess.Start("try-acquire", path); + await (await contender.ReadLineAsync()).Should().BeEqualTo(acquired ? "acquired" : "busy"); + await (await contender.WaitForExitAsync()).Should().BeEqualTo(acquired ? 0 : 2); + } + + private sealed class TemporaryDirectory : IDisposable + { + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("v2rayn-instance-lock-tests-"); + public string Path => _directory.FullName; + public void Dispose() => _directory.Delete(recursive: true); + } + private sealed class LockProcess : IAsyncDisposable + { + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20); + private readonly Process _process; + private readonly Task _standardError; + + private LockProcess(Process process) + { + _process = process; + _standardError = process.StandardError.ReadToEndAsync(); + } + + public int Id => _process.Id; + + public static LockProcess Start(string mode, string path) + { + var host = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + var start = new ProcessStartInfo(string.IsNullOrWhiteSpace(host) ? "dotnet" : host) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add("exec"); + start.ArgumentList.Add("--runtimeconfig"); + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, typeof(WebInstanceLockProcessTests).Assembly.GetName().Name + ".runtimeconfig.json")); + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "ProcessHelper", "InstanceLockProcessHelper.dll")); + start.ArgumentList.Add(mode); + start.ArgumentList.Add(path); + return new LockProcess(Process.Start(start) ?? throw new InvalidOperationException("Unable to start the instance-lock helper.")); + } + + public async Task ReadLineAsync() + { + var line = await _process.StandardOutput.ReadLineAsync().WaitAsync(Timeout); + return line ?? throw new InvalidOperationException($"Instance-lock helper exited without a reply: {await _standardError.WaitAsync(Timeout)}"); + } + + public async Task SendAsync(string command) + { + await _process.StandardInput.WriteLineAsync(command); + await _process.StandardInput.FlushAsync(); + } + + public async Task WaitForExitAsync() + { + await _process.WaitForExitAsync().WaitAsync(Timeout); + return _process.ExitCode; + } + + public async Task KillAsync() + { + try + { + if (!_process.HasExited) _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // The helper may exit between the check and Kill. + } + await _process.WaitForExitAsync().WaitAsync(Timeout); + } + + public async ValueTask DisposeAsync() + { + try + { + await KillAsync(); + await _standardError.WaitAsync(Timeout); + } + finally + { + _process.Dispose(); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj index d79249ce..80383fb3 100644 --- a/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj +++ b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj @@ -17,6 +17,8 @@ + + @@ -24,4 +26,9 @@ + + + + +