From 6288b3909e50cd9ff80f60cb429ca76bcc5dd9ba Mon Sep 17 00:00:00 2001 From: Aleksey Fedorov <320380254+Dv2Corn@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:31:06 +1000 Subject: [PATCH] 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 --- .../Handler/AutoStartupHandlerTests.cs | 81 +++++++++++++++++++ .../ServiceLib/Handler/AutoStartupHandler.cs | 13 ++- 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 v2rayN/ServiceLib.Tests/Handler/AutoStartupHandlerTests.cs diff --git a/v2rayN/ServiceLib.Tests/Handler/AutoStartupHandlerTests.cs b/v2rayN/ServiceLib.Tests/Handler/AutoStartupHandlerTests.cs new file mode 100644 index 00000000..cd673b3b --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Handler/AutoStartupHandlerTests.cs @@ -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>, 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); + } + } +} diff --git a/v2rayN/ServiceLib/Handler/AutoStartupHandler.cs b/v2rayN/ServiceLib/Handler/AutoStartupHandler.cs index df0c2098..08b3c5bd 100644 --- a/v2rayN/ServiceLib/Handler/AutoStartupHandler.cs +++ b/v2rayN/ServiceLib/Handler/AutoStartupHandler.cs @@ -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")]