mirror of
https://github.com/2dust/v2rayN.git
synced 2026-11-03 11:05:08 +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
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
identity:
|
||||||
name: Resolve API build identity
|
name: Resolve API build identity
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ public sealed class WebInstanceLock : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
string ownerText;
|
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)
|
if (descriptor < 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -163,6 +165,7 @@ public sealed class WebInstanceLock : IDisposable
|
|||||||
private const int OpenReadWrite = 2;
|
private const int OpenReadWrite = 2;
|
||||||
private const int OpenCreate = 64;
|
private const int OpenCreate = 64;
|
||||||
private const int OpenCloseOnExec = 524288;
|
private const int OpenCloseOnExec = 524288;
|
||||||
|
private const int OpenCloseOnExecMacOS = 0x01000000;
|
||||||
private const uint UserReadWriteMode = 0x180;
|
private const uint UserReadWriteMode = 0x180;
|
||||||
private const long WindowsLockOffset = 1024 * 1024;
|
private const long WindowsLockOffset = 1024 * 1024;
|
||||||
private const long WindowsLockLength = 1;
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\v2rayN.WebAPI.csproj" />
|
<ProjectReference Include="..\v2rayN.WebAPI.csproj" />
|
||||||
|
<Compile Remove="ProcessHelper/**" />
|
||||||
|
<ProjectReference Include="ProcessHelper/InstanceLockProcessHelper.csproj" ReferenceOutputAssembly="false" OutputItemType="_InstanceLockProcessHelper" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -24,4 +26,9 @@
|
|||||||
<Using Include="TUnit.Assertions.Should" />
|
<Using Include="TUnit.Assertions.Should" />
|
||||||
<Using Include="TUnit.Assertions.Should.Extensions" />
|
<Using Include="TUnit.Assertions.Should.Extensions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<Target Name="CopyInstanceLockProcessHelper" AfterTargets="CopyFilesToOutputDirectory">
|
||||||
|
<Copy SourceFiles="@(_InstanceLockProcessHelper)" DestinationFolder="$(TargetDir)ProcessHelper" SkipUnchangedFiles="true">
|
||||||
|
<Output TaskParameter="CopiedFiles" ItemName="FileWrites" />
|
||||||
|
</Copy>
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user