From 0a0ec5cefc4d2fd737c0ec22a335e5bc864622ea Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Tue, 30 Jun 2026 12:07:25 +0200 Subject: [PATCH] Fix multibox support on Linux (#1566) Co-authored-by: Alexandru Macocian --- Daybreak.API/EntryPoint.cs | 38 +++- .../Services/Api/ApiScanningService.cs | 2 +- .../Services/Toolbox/ToolboxService.cs | 4 +- Daybreak.Linux/Services/Api/PidProvider.cs | 177 +++++++++++++++++- .../Services/Injection/DaybreakInjector.cs | 5 +- .../Services/Wine/IWinePidMapper.cs | 13 +- Daybreak.Linux/Services/Wine/WinePidMapper.cs | 63 ++++++- Daybreak.Shared/Services/Api/IPidProvider.cs | 3 +- .../Services/Toolbox/ToolboxServiceTests.cs | 135 +++++++++++++ Daybreak.Windows/Services/Api/PidProvider.cs | 2 +- 10 files changed, 418 insertions(+), 24 deletions(-) create mode 100644 Daybreak.Tests/Services/Toolbox/ToolboxServiceTests.cs diff --git a/Daybreak.API/EntryPoint.cs b/Daybreak.API/EntryPoint.cs index e685175b..1060e32c 100644 --- a/Daybreak.API/EntryPoint.cs +++ b/Daybreak.API/EntryPoint.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Extensions.Core; using System.Logging; using System.Net.NetworkInformation; +using System.Reflection; using System.Runtime.InteropServices; using Daybreak.API.Configuration; using Daybreak.API.Extensions; @@ -22,8 +23,12 @@ namespace Daybreak.API; public class EntryPoint { private const int StartPort = 5080; + private const string GwcaDllName = "gwca.dll"; private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(5); private static readonly CancellationTokenSource CancellationTokenSource = new(); + private static readonly object GwcaResolverLock = new(); + private static IntPtr gwcaHandle; + private static bool gwcaResolverRegistered; [UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread] [RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")] @@ -192,7 +197,8 @@ public class EntryPoint { if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true) { - NativeLibrary.Load(module.FileName); + var handle = NativeLibrary.Load(module.FileName); + RegisterGwcaResolver(handle, module.FileName, logger); logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}"); return; } @@ -213,7 +219,8 @@ public class EntryPoint var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll"); if (File.Exists(gwcaPath)) { - NativeLibrary.Load(gwcaPath); + var handle = NativeLibrary.Load(gwcaPath); + RegisterGwcaResolver(handle, gwcaPath, logger); logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}"); } else @@ -221,4 +228,31 @@ public class EntryPoint logger.LogError($"gwca.dll not found at {gwcaPath}"); } } + + private static void RegisterGwcaResolver(IntPtr handle, string path, ScopedLogger logger) + { + lock (GwcaResolverLock) + { + gwcaHandle = handle; + if (gwcaResolverRegistered) + { + logger.LogDebug($"Updated gwca.dll resolver handle for {path}"); + return; + } + + NativeLibrary.SetDllImportResolver(typeof(GWCA).Assembly, ResolveNativeLibrary); + gwcaResolverRegistered = true; + logger.LogDebug($"Registered gwca.dll import resolver for {path}"); + } + } + + private static IntPtr ResolveNativeLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (string.Equals(libraryName, GwcaDllName, StringComparison.OrdinalIgnoreCase)) + { + return gwcaHandle; + } + + return IntPtr.Zero; + } } diff --git a/Daybreak.Core/Services/Api/ApiScanningService.cs b/Daybreak.Core/Services/Api/ApiScanningService.cs index 1d1eb918..94ad2d95 100644 --- a/Daybreak.Core/Services/Api/ApiScanningService.cs +++ b/Daybreak.Core/Services/Api/ApiScanningService.cs @@ -131,7 +131,7 @@ public sealed class ApiScanningService( } // Convert the reported PID (Wine PID on Linux) to system PID - var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable); + var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable, port); var uri = new Uri($"http://localhost:{port}"); scopedLogger.LogDebug( diff --git a/Daybreak.Core/Services/Toolbox/ToolboxService.cs b/Daybreak.Core/Services/Toolbox/ToolboxService.cs index fdf8bd29..4ebe0a50 100644 --- a/Daybreak.Core/Services/Toolbox/ToolboxService.cs +++ b/Daybreak.Core/Services/Toolbox/ToolboxService.cs @@ -133,9 +133,9 @@ internal sealed class ToolboxService( public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask; - public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask; + public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsCreatedContext.ApplicationLauncherContext.Process, cancellationToken); - public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => this.LaunchToolbox(guildWarsStartedContext.ApplicationLauncherContext.Process, cancellationToken); + public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask; public IEnumerable GetCustomArguments() => []; diff --git a/Daybreak.Linux/Services/Api/PidProvider.cs b/Daybreak.Linux/Services/Api/PidProvider.cs index 20a5e66c..0efe4a4d 100644 --- a/Daybreak.Linux/Services/Api/PidProvider.cs +++ b/Daybreak.Linux/Services/Api/PidProvider.cs @@ -1,22 +1,191 @@ using Daybreak.Linux.Services.Wine; using Daybreak.Shared.Services.Api; +using System.Globalization; namespace Daybreak.Linux.Services.Api; /// /// Linux implementation of . -/// On Linux, the reported PID is a Wine-internal PID that must be -/// converted to the actual Linux system PID via . +/// On Linux, the reported PID is a Wine-internal PID. The most reliable way to map an +/// API instance to its Linux process is via the TCP port it listens on: we resolve the +/// owning Linux PID from the listening socket. This disambiguates multiple concurrent +/// Guild Wars instances, which a Wine-PID-by-name lookup cannot do. /// public sealed class PidProvider(IWinePidMapper winePidMapper) : IPidProvider { private readonly IWinePidMapper winePidMapper = winePidMapper; /// - public int ResolveSystemPid(int reportedPid, string executableName) + public int ResolveSystemPid(int reportedPid, string executableName, int? port = null) { - // On Linux, the reported PID is a Wine PID. Convert to Linux system PID. + if (port is not null && TryGetProcessIdForTcpListener(port.Value, executableName) is { } listenerPid) + { + return listenerPid; + } + + // Fallback: the reported PID is a Wine PID. Convert to Linux system PID by name. var linuxPid = this.winePidMapper.WinePidToLinuxPid(reportedPid, executableName); return linuxPid ?? reportedPid; } + + /// + /// Resolves the Linux PID that owns the listening socket on . + /// Under Wine the listening socket fd is shared between the game process and the + /// shared wineserver process, so candidate processes are filtered by + /// to select the game process and exclude wineserver. + /// + private static int? TryGetProcessIdForTcpListener(int port, string executableName) + { + try + { + var socketInodes = GetTcpListenerSocketInodes("/proc/net/tcp", port) + .Concat(GetTcpListenerSocketInodes("/proc/net/tcp6", port)) + .ToHashSet(StringComparer.Ordinal); + if (socketInodes.Count == 0) + { + return null; + } + + foreach (var procDir in Directory.EnumerateDirectories("/proc")) + { + if (!int.TryParse(Path.GetFileName(procDir), out var pid)) + { + continue; + } + + // Only consider processes whose command line references the target + // executable. This excludes wineserver (which shares the socket fd) + // and limits the fd scan to the handful of Guild Wars processes. + if (!ProcessReferencesExecutable(procDir, executableName)) + { + continue; + } + + if (ProcessOwnsSocket(procDir, socketInodes)) + { + return pid; + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return null; + } + + private static bool ProcessReferencesExecutable(string procDir, string executableName) + { + try + { + var cmdline = File.ReadAllText(Path.Combine(procDir, "cmdline")); + return cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool ProcessOwnsSocket(string procDir, HashSet socketInodes) + { + var fdDir = Path.Combine(procDir, "fd"); + if (!Directory.Exists(fdDir)) + { + return false; + } + + try + { + foreach (var fd in Directory.EnumerateFiles(fdDir)) + { + string? linkTarget; + try + { + linkTarget = new FileInfo(fd).LinkTarget; + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + + if (linkTarget is not null && + TryParseSocketInode(linkTarget, out var inode) && + socketInodes.Contains(inode)) + { + return true; + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return false; + } + + private static IEnumerable GetTcpListenerSocketInodes(string procNetPath, int port) + { + if (!File.Exists(procNetPath)) + { + yield break; + } + + foreach (var line in File.ReadLines(procNetPath).Skip(1)) + { + var columns = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (columns.Length < 10 || + !IsListeningSocket(columns[3]) || + !TryParsePort(columns[1], out var socketPort) || + socketPort != port) + { + continue; + } + + yield return columns[9]; + } + } + + private static bool TryParsePort(string localAddress, out int port) + { + port = default; + var separatorIndex = localAddress.LastIndexOf(':'); + if (separatorIndex < 0 || + separatorIndex == localAddress.Length - 1 || + !int.TryParse(localAddress[(separatorIndex + 1)..], NumberStyles.HexNumber, null, out port)) + { + return false; + } + + return true; + } + + private static bool IsListeningSocket(string state) => state.Equals("0A", StringComparison.OrdinalIgnoreCase); + + private static bool TryParseSocketInode(string linkTarget, out string inode) + { + inode = string.Empty; + const string socketPrefix = "socket:["; + if (!linkTarget.StartsWith(socketPrefix, StringComparison.Ordinal) || !linkTarget.EndsWith(']')) + { + return false; + } + + inode = linkTarget[socketPrefix.Length..^1]; + return inode.Length > 0; + } } diff --git a/Daybreak.Linux/Services/Injection/DaybreakInjector.cs b/Daybreak.Linux/Services/Injection/DaybreakInjector.cs index 195743c7..d151899f 100644 --- a/Daybreak.Linux/Services/Injection/DaybreakInjector.cs +++ b/Daybreak.Linux/Services/Injection/DaybreakInjector.cs @@ -193,8 +193,9 @@ public class DaybreakInjector( // Convert Wine PID to Linux PID so the rest of Daybreak can use Process.GetProcessById() if (processId > 0) { - var executableName = Path.GetFileName(executablePath); - var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executableName); + // Match on the full executable path so concurrent Guild Wars instances + // (different install directories) are not confused for one another. + var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId, executablePath); if (linuxPid is not null) { scopedLogger.LogInformation( diff --git a/Daybreak.Linux/Services/Wine/IWinePidMapper.cs b/Daybreak.Linux/Services/Wine/IWinePidMapper.cs index fd452aa1..ed089831 100644 --- a/Daybreak.Linux/Services/Wine/IWinePidMapper.cs +++ b/Daybreak.Linux/Services/Wine/IWinePidMapper.cs @@ -7,14 +7,17 @@ namespace Daybreak.Linux.Services.Wine; public interface IWinePidMapper { /// - /// Converts a Wine-internal PID to a Linux system PID. - /// Scans /proc for a process whose cmdline contains the executable name - /// and whose environment references our Wine prefix. + /// Converts a Wine-internal PID to a Linux system PID by scanning /proc. + /// When is a full path it is matched against each + /// candidate's full executable path, which uniquely identifies the process even + /// when multiple Guild Wars instances (different install directories) run at once. + /// When only a file name is supplied, the first matching process is returned. /// /// The Wine-internal process ID (unused for lookup, kept for logging). - /// The executable name (e.g. "Gw.exe") to search for in /proc. + /// The executable to match: a full Linux path (preferred, for + /// unambiguous matching) or a bare file name (e.g. "Gw.exe") as a best-effort fallback. /// The Linux PID, or null if not found. - int? WinePidToLinuxPid(int winePid, string executableName); + int? WinePidToLinuxPid(int winePid, string executable); /// /// Converts a Linux system PID back to a Wine-internal PID. diff --git a/Daybreak.Linux/Services/Wine/WinePidMapper.cs b/Daybreak.Linux/Services/Wine/WinePidMapper.cs index 8638a3ac..9e3b3b98 100644 --- a/Daybreak.Linux/Services/Wine/WinePidMapper.cs +++ b/Daybreak.Linux/Services/Wine/WinePidMapper.cs @@ -17,8 +17,12 @@ public sealed class WinePidMapper( private readonly ILogger logger = logger; /// - public int? WinePidToLinuxPid(int winePid, string executableName) + public int? WinePidToLinuxPid(int winePid, string executable) { + var targetFileName = Path.GetFileName(executable); + var matchByFullPath = executable.Contains('/') || executable.Contains('\\'); + var targetFullPath = matchByFullPath ? TryGetFullPath(executable) : null; + try { foreach (var procDir in Directory.EnumerateDirectories("/proc")) @@ -38,14 +42,35 @@ public sealed class WinePidMapper( } var cmdline = File.ReadAllText(cmdlinePath); - if (!cmdline.Contains(executableName, StringComparison.OrdinalIgnoreCase)) + if (cmdline.Length == 0) { continue; } + // cmdline is null-separated; find the segment pointing at the executable. + var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries); + var exeSegment = segments.FirstOrDefault(s => + s.EndsWith(targetFileName, StringComparison.OrdinalIgnoreCase)); + if (exeSegment is null) + { + continue; + } + + // When a full path was supplied, require an exact path match so that + // concurrent Guild Wars instances in different directories are not confused. + if (matchByFullPath) + { + var candidateFullPath = TryGetFullPath(WinePathToLinuxPath(exeSegment)); + if (candidateFullPath is null || + !string.Equals(candidateFullPath, targetFullPath, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + this.logger.LogDebug( - "Resolved Wine PID {WinePid} -> Linux PID {LinuxPid} for {ExeName}", - winePid, pid, executableName + "Resolved Wine PID {WinePid} -> Linux PID {LinuxPid} for {Executable}", + winePid, pid, executable ); return pid; @@ -56,13 +81,39 @@ public sealed class WinePidMapper( } catch (Exception ex) { - this.logger.LogError(ex, "Error scanning /proc for Wine process {ExeName}", executableName); + this.logger.LogError(ex, "Error scanning /proc for Wine process {Executable}", executable); } - this.logger.LogWarning("Could not find Linux PID for Wine PID {WinePid} ({ExeName})", winePid, executableName); + this.logger.LogWarning("Could not find Linux PID for Wine PID {WinePid} ({Executable})", winePid, executable); return null; } + private static string? TryGetFullPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch (Exception) + { + return null; + } + } + + /// + /// Converts a Wine Z: drive path back to a Linux path. + /// "Z:\home\user\Guild Wars\Gw.exe" -> "/home/user/Guild Wars/Gw.exe". + /// + private static string WinePathToLinuxPath(string winePath) + { + if (winePath.StartsWith("Z:", StringComparison.OrdinalIgnoreCase)) + { + winePath = winePath[2..]; + } + + return winePath.Replace('\\', '/'); + } + /// public int? LinuxPidToWinePid(int linuxPid) { diff --git a/Daybreak.Shared/Services/Api/IPidProvider.cs b/Daybreak.Shared/Services/Api/IPidProvider.cs index 0423e6d7..9665fe71 100644 --- a/Daybreak.Shared/Services/Api/IPidProvider.cs +++ b/Daybreak.Shared/Services/Api/IPidProvider.cs @@ -12,6 +12,7 @@ public interface IPidProvider /// /// The process ID reported by the API (may be a Wine PID on Linux). /// The executable name to search for (e.g., "Gw.exe"). + /// The local TCP port owned by the process, when available. /// The system process ID, or the original PID if conversion fails or is not needed. - int ResolveSystemPid(int reportedPid, string executableName); + int ResolveSystemPid(int reportedPid, string executableName, int? port = null); } diff --git a/Daybreak.Tests/Services/Toolbox/ToolboxServiceTests.cs b/Daybreak.Tests/Services/Toolbox/ToolboxServiceTests.cs new file mode 100644 index 00000000..35cc0a58 --- /dev/null +++ b/Daybreak.Tests/Services/Toolbox/ToolboxServiceTests.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using Daybreak.Configuration.Options; +using Daybreak.Services.Toolbox; +using Daybreak.Services.Toolbox.Utilities; +using Daybreak.Shared.Exceptions; +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Mods; +using Daybreak.Shared.Services.BuildTemplates; +using Daybreak.Shared.Services.Injection; +using Daybreak.Shared.Services.Notifications; +using Daybreak.Shared.Services.Options; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Daybreak.Tests.Services.Toolbox; + +/// +/// Guards the mod lifecycle phase in which GWToolbox is injected. Toolbox must inject during +/// (before Daybreak.API) and must NOT inject +/// during . This ordering is what lets Toolbox +/// load its bundled gwca.dll first so Daybreak.API reuses the same module. +/// +[TestClass] +public sealed class ToolboxServiceTests +{ + private readonly IOptionsProvider optionsProvider = Substitute.For(); + private readonly IBuildTemplateManager buildTemplateManager = Substitute.For(); + private readonly INotificationService notificationService = Substitute.For(); + private readonly IProcessInjector processInjector = Substitute.For(); + private readonly IToolboxClient toolboxClient = Substitute.For(); + private readonly IOptionsMonitor toolboxOptions = Substitute.For>(); + private readonly ToolboxOptions options = new(); + private readonly Process process = new(); + private readonly ToolboxService service; + + public ToolboxServiceTests() + { + this.toolboxOptions.CurrentValue.Returns(this.options); + this.service = new ToolboxService( + this.optionsProvider, + this.buildTemplateManager, + this.notificationService, + this.processInjector, + this.toolboxClient, + this.toolboxOptions, + Substitute.For>()); + } + + [TestCleanup] + public void Cleanup() => this.process.Dispose(); + + private GuildWarsCreatedContext CreatedContext() => new() + { + ApplicationLauncherContext = this.LauncherContext(), + }; + + private GuildWarsStartedContext StartedContext() => new() + { + ApplicationLauncherContext = this.LauncherContext(), + }; + + private ApplicationLauncherContext LauncherContext() => new() + { + ExecutablePath = "Gw.exe", + Process = this.process, + ProcessId = 1234, + }; + + [TestMethod] + public async Task OnGuildWarsStarted_WhenEnabled_DoesNotInjectToolbox() + { + this.options.Enabled = true; + var reachedLaunch = false; + this.processInjector + .Inject(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { reachedLaunch = true; return Task.FromResult(true); }); + + // OnGuildWarsStarted must be a pure no-op now that injection moved to OnGuildWarsCreated. + await this.service.OnGuildWarsStarted(this.StartedContext(), CancellationToken.None); + + reachedLaunch.Should().BeFalse("Toolbox injection moved out of OnGuildWarsStarted"); + await this.processInjector + .DidNotReceive() + .Inject(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task OnGuildWarsStarted_WhenDisabled_DoesNotInjectToolbox() + { + this.options.Enabled = false; + + await this.service.OnGuildWarsStarted(this.StartedContext(), CancellationToken.None); + + await this.processInjector + .DidNotReceive() + .Inject(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task OnGuildWarsCreated_WhenDisabled_DoesNotInjectToolbox() + { + this.options.Enabled = false; + + await this.service.OnGuildWarsCreated(this.CreatedContext(), CancellationToken.None); + + await this.processInjector + .DidNotReceive() + .Inject(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task OnGuildWarsCreated_WhenEnabled_DrivesToolboxLaunch() + { + this.options.Enabled = true; + var reachedLaunch = false; + this.processInjector + .Inject(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { reachedLaunch = true; return Task.FromResult(true); }); + + try + { + await this.service.OnGuildWarsCreated(this.CreatedContext(), CancellationToken.None); + } + catch (ExecutableNotFoundException) + { + // Reached LaunchToolbox, but the GWToolbox dll is not installed in this environment. + // This still proves OnGuildWarsCreated routes into the toolbox launch path. + reachedLaunch = true; + } + + reachedLaunch.Should().BeTrue("OnGuildWarsCreated must inject Toolbox before Daybreak.API"); + } +} diff --git a/Daybreak.Windows/Services/Api/PidProvider.cs b/Daybreak.Windows/Services/Api/PidProvider.cs index 1b76987f..742de82e 100644 --- a/Daybreak.Windows/Services/Api/PidProvider.cs +++ b/Daybreak.Windows/Services/Api/PidProvider.cs @@ -9,7 +9,7 @@ namespace Daybreak.Windows.Services.Api; public sealed class PidProvider : IPidProvider { /// - public int ResolveSystemPid(int reportedPid, string executableName) + public int ResolveSystemPid(int reportedPid, string executableName, int? port = null) { // On Windows, the reported PID is the actual system PID return reportedPid;