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 @@
+
+
+
+
+