fix(macos): do not unload the LaunchAgent when updating auto-start (#10567)

Saving the settings runs AutoStartupHandler.UpdateTask, which on macOS
first ran "launchctl unload -w" on the v2rayN LaunchAgent. When v2rayN
was started by that agent at login, launchd terminates the job processes
on unload, so v2rayN received SIGTERM and exited before it could delete
and re-create the plist. The "-w" also left the job disabled, so the
next login did not start v2rayN although auto-start was still enabled.

The agent only has RunAtLoad, so unloading it has no effect other than
killing its processes. Removing the plist is enough to stop the next
start; enabling still writes the plist and runs "launchctl load -w".

Fixes #10522
This commit is contained in:
Aleksey Fedorov
2026-11-13 16:31:06 +10:00
committed by GitHub
parent d5338f673c
commit 6288b3909e
2 changed files with 86 additions and 8 deletions
@@ -0,0 +1,81 @@
namespace ServiceLib.Tests.Handler;
// launchd terminates the processes of a LaunchAgent job when the job is unloaded. When v2rayN was
// started by its own LaunchAgent at login, "launchctl unload" on that agent kills v2rayN itself.
// These tests run the macOS code paths against a fake launchctl that only records its arguments.
[NotInParallel]
public class AutoStartupHandlerTests
{
[Test]
public async Task MacOS_SaveWithAutoRunEnabled_ShouldNotUnloadLaunchAgent()
{
await WithFakeLaunchctl(async (plistPath, readCalls) =>
{
await InvokeMacOS("SetTaskOSX");
// UpdateTask on macOS when settings are saved with auto-start still enabled
await InvokeMacOS("ClearTaskOSX");
await InvokeMacOS("SetTaskOSX");
var calls = await readCalls();
await calls.Any(call => call.StartsWith("unload")).Should().BeFalse();
await calls.Should().HaveCount(2);
await calls[1].Should().BeEqualTo($"load -w {plistPath}");
await File.ReadAllText(plistPath).Contains(Utils.GetExePath()).Should().BeTrue();
});
}
[Test]
public async Task MacOS_DisableAutoRun_ShouldRemovePlistWithoutUnload()
{
await WithFakeLaunchctl(async (plistPath, readCalls) =>
{
await InvokeMacOS("SetTaskOSX");
// UpdateTask on macOS when auto-start is turned off
await InvokeMacOS("ClearTaskOSX");
var calls = await readCalls();
await calls.Any(call => call.StartsWith("unload")).Should().BeFalse();
await File.Exists(plistPath).Should().BeFalse();
});
}
private static async Task InvokeMacOS(string methodName)
{
var method = typeof(AutoStartupHandler).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic)!;
await (Task)method.Invoke(null, null)!;
}
private static async Task WithFakeLaunchctl(Func<string, Func<Task<string[]>>, Task> test)
{
if (OperatingSystem.IsWindows())
{
return;
}
var root = Directory.CreateTempSubdirectory("v2rayN-autostart-").FullName;
var binDir = Directory.CreateDirectory(Path.Combine(root, "bin")).FullName;
var logPath = Path.Combine(root, "launchctl.log");
var launchctl = Path.Combine(binDir, "launchctl");
await File.WriteAllTextAsync(launchctl, $"#!/bin/sh\necho \"$*\" >> \"{logPath}\"\n");
File.SetUnixFileMode(launchctl, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
var oldHome = Environment.GetEnvironmentVariable("HOME");
var oldPath = Environment.GetEnvironmentVariable("PATH");
try
{
Environment.SetEnvironmentVariable("HOME", root);
Environment.SetEnvironmentVariable("PATH", $"{binDir}:{oldPath}");
var plistPath = Path.Combine(root, "Library", "LaunchAgents", $"{Global.AppName}-LaunchAgent.plist");
await test(plistPath, async () => File.Exists(logPath) ? await File.ReadAllLinesAsync(logPath) : []);
}
finally
{
Environment.SetEnvironmentVariable("HOME", oldHome);
Environment.SetEnvironmentVariable("PATH", oldPath);
Directory.Delete(root, true);
}
}
}
@@ -251,19 +251,16 @@ public static class AutoStartupHandler
{
try
{
var launchAgentPath = GetLaunchAgentPathMacOS();
if (File.Exists(launchAgentPath))
{
var args = new[] { "-c", $"launchctl unload -w \"{launchAgentPath}\"" };
await Utils.GetCliWrapOutput(Global.LinuxBash, args);
File.Delete(launchAgentPath);
}
// Do not "launchctl unload" the agent: launchd then terminates the job's processes, which is
// v2rayN itself when it was started at login. The agent only runs at load, so removing the
// plist is enough to stop the next start.
File.Delete(GetLaunchAgentPathMacOS());
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
await Task.CompletedTask;
}
[SupportedOSPlatform("macos")]