mirror of
https://github.com/2dust/v2rayN.git
synced 2026-11-02 18:45:00 +03:00
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:
committed by
GitHub
parent
756916978d
commit
ebabf79017
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user