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
This commit is contained in:
Miheichev Aleksandr Sergeevich
2026-11-02 01:20:08 +00:00
committed by GitHub
parent 756916978d
commit ebabf79017
6 changed files with 255 additions and 2 deletions
+18
View File
@@ -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
@@ -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;
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<UseAppHost>false</UseAppHost>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../Launcher/WebInstanceLock.cs" Link="WebInstanceLock.cs" />
</ItemGroup>
</Project>
@@ -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;
@@ -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<string> _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<string> 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<int> 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();
}
}
}
}
@@ -17,6 +17,8 @@
<ItemGroup>
<ProjectReference Include="..\v2rayN.WebAPI.csproj" />
<Compile Remove="ProcessHelper/**" />
<ProjectReference Include="ProcessHelper/InstanceLockProcessHelper.csproj" ReferenceOutputAssembly="false" OutputItemType="_InstanceLockProcessHelper" />
</ItemGroup>
<ItemGroup>
@@ -24,4 +26,9 @@
<Using Include="TUnit.Assertions.Should" />
<Using Include="TUnit.Assertions.Should.Extensions" />
</ItemGroup>
<Target Name="CopyInstanceLockProcessHelper" AfterTargets="CopyFilesToOutputDirectory">
<Copy SourceFiles="@(_InstanceLockProcessHelper)" DestinationFolder="$(TargetDir)ProcessHelper" SkipUnchangedFiles="true">
<Output TaskParameter="CopiedFiles" ItemName="FileWrites" />
</Copy>
</Target>
</Project>