Fix multibox support on Linux (#1566)

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
2026-06-30 12:07:25 +02:00
parent 0d31587855
commit 0a0ec5cefc
10 changed files with 418 additions and 24 deletions
+173 -4
View File
@@ -1,22 +1,191 @@
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Services.Api;
using System.Globalization;
namespace Daybreak.Linux.Services.Api;
/// <summary>
/// Linux implementation of <see cref="IPidProvider"/>.
/// On Linux, the reported PID is a Wine-internal PID that must be
/// converted to the actual Linux system PID via <see cref="IWinePidMapper"/>.
/// 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.
/// </summary>
public sealed class PidProvider(IWinePidMapper winePidMapper) : IPidProvider
{
private readonly IWinePidMapper winePidMapper = winePidMapper;
/// <inheritdoc />
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;
}
/// <summary>
/// Resolves the Linux PID that owns the listening socket on <paramref name="port"/>.
/// Under Wine the listening socket fd is shared between the game process and the
/// shared <c>wineserver</c> process, so candidate processes are filtered by
/// <paramref name="executableName"/> to select the game process and exclude wineserver.
/// </summary>
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<string> 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<string> 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;
}
}
@@ -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(
@@ -7,14 +7,17 @@ namespace Daybreak.Linux.Services.Wine;
public interface IWinePidMapper
{
/// <summary>
/// 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 <paramref name="executable"/> 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.
/// </summary>
/// <param name="winePid">The Wine-internal process ID (unused for lookup, kept for logging).</param>
/// <param name="executableName">The executable name (e.g. "Gw.exe") to search for in /proc.</param>
/// <param name="executable">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.</param>
/// <returns>The Linux PID, or null if not found.</returns>
int? WinePidToLinuxPid(int winePid, string executableName);
int? WinePidToLinuxPid(int winePid, string executable);
/// <summary>
/// Converts a Linux system PID back to a Wine-internal PID.
+57 -6
View File
@@ -17,8 +17,12 @@ public sealed class WinePidMapper(
private readonly ILogger<WinePidMapper> logger = logger;
/// <inheritdoc />
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;
}
}
/// <summary>
/// Converts a Wine Z: drive path back to a Linux path.
/// "Z:\home\user\Guild Wars\Gw.exe" -> "/home/user/Guild Wars/Gw.exe".
/// </summary>
private static string WinePathToLinuxPath(string winePath)
{
if (winePath.StartsWith("Z:", StringComparison.OrdinalIgnoreCase))
{
winePath = winePath[2..];
}
return winePath.Replace('\\', '/');
}
/// <inheritdoc />
public int? LinuxPidToWinePid(int linuxPid)
{