diff --git a/Dalamud.Test/Pipes/DalamudUriTests.cs b/Dalamud.Test/Rpc/DalamudUriTests.cs
similarity index 98%
rename from Dalamud.Test/Pipes/DalamudUriTests.cs
rename to Dalamud.Test/Rpc/DalamudUriTests.cs
index 4977f3814..b371a5698 100644
--- a/Dalamud.Test/Pipes/DalamudUriTests.cs
+++ b/Dalamud.Test/Rpc/DalamudUriTests.cs
@@ -1,10 +1,11 @@
using System;
using System.Linq;
-using Dalamud.Networking.Pipes;
+using Dalamud.Networking.Rpc.Model;
+
using Xunit;
-namespace Dalamud.Test.Pipes
+namespace Dalamud.Test.Rpc
{
public class DalamudUriTests
{
diff --git a/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs b/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs
deleted file mode 100644
index 78df27323..000000000
--- a/Dalamud/Networking/Pipes/Rpc/RpcHostService.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using Dalamud.Logging.Internal;
-
-namespace Dalamud.Networking.Pipes.Rpc;
-
-///
-/// The Dalamud service repsonsible for hosting the RPC.
-///
-[ServiceManager.EarlyLoadedService]
-internal class RpcHostService : IServiceType, IInternalDisposableService
-{
- private readonly ModuleLog log = new("RPC");
- private readonly PipeRpcHost host;
-
- ///
- /// Initializes a new instance of the class.
- ///
- [ServiceManager.ServiceConstructor]
- public RpcHostService()
- {
- this.host = new PipeRpcHost();
- this.host.Start();
-
- this.log.Information("RpcHostService started on pipe {Pipe}", this.host.PipeName);
- }
-
- ///
- /// Gets the RPC host to drill down.
- ///
- public PipeRpcHost Host => this.host;
-
- ///
- /// Add a new service Object to the RPC host.
- ///
- /// The object to add.
- public void AddService(object service) => this.host.AddService(service);
-
- ///
- /// Add a new standalone method to the RPC host.
- ///
- /// The method name to add.
- /// The handler to add.
- public void AddMethod(string name, Delegate handler) => this.host.AddMethod(name, handler);
-
- ///
- public void DisposeService()
- {
- this.host.Dispose();
- }
-}
diff --git a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs b/Dalamud/Networking/Rpc/Api/PluginLinkHandler.cs
similarity index 93%
rename from Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs
rename to Dalamud/Networking/Rpc/Api/PluginLinkHandler.cs
index 78fbb0d82..e9372bf0e 100644
--- a/Dalamud/Networking/Pipes/Api/PluginLinkHandler.cs
+++ b/Dalamud/Networking/Rpc/Api/PluginLinkHandler.cs
@@ -3,12 +3,14 @@
using Dalamud.Console;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
-using Dalamud.Networking.Pipes.Internal;
+using Dalamud.Networking.Rpc.Model;
+using Dalamud.Networking.Rpc.Service;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
+
#pragma warning disable DAL_RPC
-namespace Dalamud.Networking.Pipes.Api;
+namespace Dalamud.Networking.Rpc.Api;
///
[PluginInterface]
diff --git a/Dalamud/Networking/Pipes/DalamudUri.cs b/Dalamud/Networking/Rpc/Model/DalamudUri.cs
similarity index 98%
rename from Dalamud/Networking/Pipes/DalamudUri.cs
rename to Dalamud/Networking/Rpc/Model/DalamudUri.cs
index 7e639cbbe..852478762 100644
--- a/Dalamud/Networking/Pipes/DalamudUri.cs
+++ b/Dalamud/Networking/Rpc/Model/DalamudUri.cs
@@ -2,7 +2,7 @@
using System.Collections.Specialized;
using System.Web;
-namespace Dalamud.Networking.Pipes;
+namespace Dalamud.Networking.Rpc.Model;
///
/// A Dalamud Uri, in the format:
diff --git a/Dalamud/Networking/Pipes/Rpc/RpcConnection.cs b/Dalamud/Networking/Rpc/RpcConnection.cs
similarity index 76%
rename from Dalamud/Networking/Pipes/Rpc/RpcConnection.cs
rename to Dalamud/Networking/Rpc/RpcConnection.cs
index 8e1c3a085..5288948eb 100644
--- a/Dalamud/Networking/Pipes/Rpc/RpcConnection.cs
+++ b/Dalamud/Networking/Rpc/RpcConnection.cs
@@ -1,34 +1,37 @@
-using System.IO.Pipes;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using Dalamud.Networking.Rpc.Service;
+
using Serilog;
+
using StreamJsonRpc;
-namespace Dalamud.Networking.Pipes.Rpc;
+namespace Dalamud.Networking.Rpc;
///
-/// A single RPC client session connected via named pipe.
+/// A single RPC client session connected via a stream (named pipe or Unix socket).
///
internal class RpcConnection : IDisposable
{
- private readonly NamedPipeServerStream pipe;
+ private readonly Stream stream;
private readonly RpcServiceRegistry registry;
private readonly CancellationTokenSource cts = new();
///
/// Initializes a new instance of the class.
///
- /// The named pipe that this connection will handle.
+ /// The stream that this connection will handle.
/// A registry of RPC services.
- public RpcConnection(NamedPipeServerStream pipe, RpcServiceRegistry registry)
+ public RpcConnection(Stream stream, RpcServiceRegistry registry)
{
this.Id = Guid.CreateVersion7();
- this.pipe = pipe;
+ this.stream = stream;
this.registry = registry;
var formatter = new JsonMessageFormatter();
- var handler = new HeaderDelimitedMessageHandler(pipe, pipe, formatter);
+ var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter);
this.Rpc = new JsonRpc(handler);
this.Rpc.AllowModificationWhileListening = true;
@@ -72,11 +75,11 @@ internal class RpcConnection : IDisposable
try
{
- this.pipe.Dispose();
+ this.stream.Dispose();
}
catch (Exception ex)
{
- Log.Debug(ex, "Error disposing pipe for client {Id}", this.Id);
+ Log.Debug(ex, "Error disposing stream for client {Id}", this.Id);
}
this.cts.Dispose();
diff --git a/Dalamud/Networking/Rpc/RpcHostService.cs b/Dalamud/Networking/Rpc/RpcHostService.cs
new file mode 100644
index 000000000..f164992eb
--- /dev/null
+++ b/Dalamud/Networking/Rpc/RpcHostService.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Dalamud.Logging.Internal;
+using Dalamud.Networking.Rpc.Transport;
+using Dalamud.Utility;
+
+namespace Dalamud.Networking.Rpc;
+
+///
+/// The Dalamud service repsonsible for hosting the RPC.
+///
+[ServiceManager.EarlyLoadedService]
+internal class RpcHostService : IServiceType, IInternalDisposableService
+{
+ private readonly ModuleLog log = new("RPC");
+ private readonly RpcServiceRegistry registry = new();
+ private readonly List transports = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ [ServiceManager.ServiceConstructor]
+ public RpcHostService()
+ {
+ this.StartUnixTransport();
+ this.StartPipeTransport();
+
+ if (this.transports.Count == 0)
+ {
+ this.log.Warning("No RPC hosts could be started on this platform");
+ }
+ }
+
+ ///
+ /// Gets all active RPC transports.
+ ///
+ public IReadOnlyList Transports => this.transports;
+
+ ///
+ /// Add a new service Object to the RPC host.
+ ///
+ /// The object to add.
+ public void AddService(object service) => this.registry.AddService(service);
+
+ ///
+ /// Add a new standalone method to the RPC host.
+ ///
+ /// The method name to add.
+ /// The handler to add.
+ public void AddMethod(string name, Delegate handler) => this.registry.AddMethod(name, handler);
+
+ ///
+ public void DisposeService()
+ {
+ foreach (var host in this.transports)
+ {
+ host.Dispose();
+ }
+
+ this.transports.Clear();
+ }
+
+ ///
+ public async Task InvokeClientAsync(Guid clientId, string method, params object[] arguments)
+ {
+ var clients = this.transports.SelectMany(t => t.Connections).ToImmutableDictionary();
+
+ if (!clients.TryGetValue(clientId, out var session))
+ throw new KeyNotFoundException($"No client {clientId}");
+
+ return await session.Rpc.InvokeAsync(method, arguments).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task BroadcastNotifyAsync(string method, params object[] arguments)
+ {
+ await foreach (var transport in this.transports.ToAsyncEnumerable().ConfigureAwait(false))
+ {
+ await transport.BroadcastNotifyAsync(method, arguments).ConfigureAwait(false);
+ }
+ }
+
+ private void StartUnixTransport()
+ {
+ var transport = new UnixRpcTransport(this.registry);
+ this.transports.Add(transport);
+ transport.Start();
+ this.log.Information("RpcHostService started Unix socket host: {Socket}", transport.SocketPath);
+ }
+
+ private void StartPipeTransport()
+ {
+ // Wine doesn't support named pipes.
+ if (Util.IsWine())
+ return;
+
+ var transport = new PipeRpcTransport(this.registry);
+ this.transports.Add(transport);
+ transport.Start();
+ this.log.Information("RpcHostService started named pipe host: {Pipe}", transport.PipeName);
+ }
+}
diff --git a/Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs b/Dalamud/Networking/Rpc/RpcServiceRegistry.cs
similarity index 98%
rename from Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs
rename to Dalamud/Networking/Rpc/RpcServiceRegistry.cs
index 71037d45e..6daea14bf 100644
--- a/Dalamud/Networking/Pipes/Rpc/RpcServiceRegistry.cs
+++ b/Dalamud/Networking/Rpc/RpcServiceRegistry.cs
@@ -3,7 +3,7 @@ using System.Threading;
using StreamJsonRpc;
-namespace Dalamud.Networking.Pipes.Rpc;
+namespace Dalamud.Networking.Rpc;
///
/// Thread-safe registry of local RPC target objects that are exposed to every connected JsonRpc session.
diff --git a/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs b/Dalamud/Networking/Rpc/Service/ClientHelloService.cs
similarity index 97%
rename from Dalamud/Networking/Pipes/Internal/ClientHelloService.cs
rename to Dalamud/Networking/Rpc/Service/ClientHelloService.cs
index 9c182561e..041bc135f 100644
--- a/Dalamud/Networking/Pipes/Internal/ClientHelloService.cs
+++ b/Dalamud/Networking/Rpc/Service/ClientHelloService.cs
@@ -3,12 +3,11 @@
using Dalamud.Data;
using Dalamud.Game;
using Dalamud.Game.ClientState;
-using Dalamud.Networking.Pipes.Rpc;
using Dalamud.Utility;
using Lumina.Excel.Sheets;
-namespace Dalamud.Networking.Pipes.Internal;
+namespace Dalamud.Networking.Rpc.Service;
///
/// A minimal service to respond with information about this client.
diff --git a/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs b/Dalamud/Networking/Rpc/Service/LinkHandlerService.cs
similarity index 97%
rename from Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs
rename to Dalamud/Networking/Rpc/Service/LinkHandlerService.cs
index 3cc4af9f4..9fa311ede 100644
--- a/Dalamud/Networking/Pipes/Internal/LinkHandlerService.cs
+++ b/Dalamud/Networking/Rpc/Service/LinkHandlerService.cs
@@ -2,10 +2,10 @@
using System.Collections.Generic;
using Dalamud.Logging.Internal;
-using Dalamud.Networking.Pipes.Rpc;
+using Dalamud.Networking.Rpc.Model;
using Dalamud.Utility;
-namespace Dalamud.Networking.Pipes.Internal;
+namespace Dalamud.Networking.Rpc.Service;
///
/// A service responsible for handling Dalamud URIs and dispatching them accordingly.
diff --git a/Dalamud/Networking/Rpc/Transport/IRpcTransport.cs b/Dalamud/Networking/Rpc/Transport/IRpcTransport.cs
new file mode 100644
index 000000000..ad7578eb4
--- /dev/null
+++ b/Dalamud/Networking/Rpc/Transport/IRpcTransport.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Dalamud.Networking.Rpc.Transport;
+
+///
+/// Interface for RPC host implementations (named pipes or Unix sockets).
+///
+internal interface IRpcTransport : IDisposable
+{
+ ///
+ /// Gets a list of active RPC connections.
+ ///
+ IReadOnlyDictionary Connections { get; }
+
+ /// Starts accepting client connections.
+ void Start();
+
+ /// Invoke an RPC request on a specific client expecting a result.
+ /// The client ID to invoke.
+ /// The method to invoke.
+ /// Any arguments to invoke.
+ /// An optional return based on the specified RPC.
+ /// The expected response type.
+ Task InvokeClientAsync(Guid clientId, string method, params object[] arguments);
+
+ /// Send a notification to all connected clients (no response expected).
+ /// The method name to broadcast.
+ /// The arguments to broadcast.
+ /// Returns a Task when completed.
+ Task BroadcastNotifyAsync(string method, params object[] arguments);
+}
diff --git a/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs b/Dalamud/Networking/Rpc/Transport/PipeRpcTransport.cs
similarity index 81%
rename from Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs
rename to Dalamud/Networking/Rpc/Transport/PipeRpcTransport.cs
index ad1cc72cd..0cefeb853 100644
--- a/Dalamud/Networking/Pipes/Rpc/PipeRpcHost.cs
+++ b/Dalamud/Networking/Rpc/Transport/PipeRpcTransport.cs
@@ -9,26 +9,28 @@ using System.Threading.Tasks;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
-namespace Dalamud.Networking.Pipes.Rpc;
+namespace Dalamud.Networking.Rpc.Transport;
///
/// Simple multi-client JSON-RPC named pipe host using StreamJsonRpc.
///
-internal class PipeRpcHost : IDisposable
+internal class PipeRpcTransport : IRpcTransport
{
private readonly ModuleLog log = new("RPC/Host");
- private readonly RpcServiceRegistry registry = new();
+ private readonly RpcServiceRegistry registry;
private readonly CancellationTokenSource cts = new();
private readonly ConcurrentDictionary sessions = new();
private Task? acceptLoopTask;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
+ /// The RPC service registry to use.
/// The pipe name to create.
- public PipeRpcHost(string? pipeName = null)
+ public PipeRpcTransport(RpcServiceRegistry registry, string? pipeName = null)
{
+ this.registry = registry;
// Default pipe name based on current process ID for uniqueness per Dalamud instance.
this.PipeName = pipeName ?? $"DalamudRPC.{Environment.ProcessId}";
}
@@ -38,16 +40,8 @@ internal class PipeRpcHost : IDisposable
///
public string PipeName { get; }
- /// Adds a local object exposing RPC methods callable by clients.
- /// An arbitrary service object that will be introspected to add to RPC.
- public void AddService(object service) => this.registry.AddService(service);
-
- ///
- /// Adds a standalone JSON-RPC method callable by clients.
- ///
- /// The name to add.
- /// The delegate that acts as the handler.
- public void AddMethod(string name, Delegate handler) => this.registry.AddMethod(name, handler);
+ ///
+ public IReadOnlyDictionary Connections => this.sessions;
/// Starts accepting client connections.
public void Start()
@@ -86,12 +80,6 @@ internal class PipeRpcHost : IDisposable
return Task.WhenAll(tasks);
}
- ///
- /// Gets a list of connected client IDs.
- ///
- /// Connected client IDs.
- public IReadOnlyCollection GetClientIds() => this.sessions.Keys.AsReadOnlyCollection();
-
///
public void Dispose()
{
diff --git a/Dalamud/Networking/Rpc/Transport/UnixRpcTransport.cs b/Dalamud/Networking/Rpc/Transport/UnixRpcTransport.cs
new file mode 100644
index 000000000..3019f5aaf
--- /dev/null
+++ b/Dalamud/Networking/Rpc/Transport/UnixRpcTransport.cs
@@ -0,0 +1,223 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Dalamud.Logging.Internal;
+using Dalamud.Utility;
+
+using TerraFX.Interop.Windows;
+
+namespace Dalamud.Networking.Rpc.Transport;
+
+///
+/// Simple multi-client JSON-RPC Unix socket host using StreamJsonRpc.
+///
+internal class UnixRpcTransport : IRpcTransport
+{
+ private readonly ModuleLog log = new("RPC/UnixHost");
+
+ private readonly RpcServiceRegistry registry;
+ private readonly CancellationTokenSource cts = new();
+ private readonly ConcurrentDictionary sessions = new();
+ private readonly string? cleanupSocketDirectory;
+
+ private Task? acceptLoopTask;
+ private Socket? listenSocket;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The RPC service registry to use.
+ /// The Unix socket path to create. If null, defaults to a path based on process ID.
+ public UnixRpcTransport(RpcServiceRegistry registry, string? socketPath = null)
+ {
+ this.registry = registry;
+
+ if (socketPath != null)
+ {
+ this.SocketPath = socketPath;
+ }
+ else
+ {
+ var dalamudConfigPath = Service.Get().StartInfo.ConfigurationPath;
+ var dalamudHome = Path.GetDirectoryName(dalamudConfigPath);
+ var socketName = $"DalamudRPC.{Environment.ProcessId}.sock";
+
+ if (dalamudHome == null)
+ {
+ this.SocketPath = Path.Combine(Path.GetTempPath(), socketName);
+ this.log.Warning("Dalamud home is empty! UDS socket will be in temp.");
+ }
+ else
+ {
+ this.SocketPath = Path.Combine(dalamudHome, socketName);
+ this.cleanupSocketDirectory = dalamudHome;
+ }
+ }
+ }
+
+ ///
+ /// Gets the path of the Unix socket this RPC host is using.
+ ///
+ public string SocketPath { get; }
+
+ ///
+ public IReadOnlyDictionary Connections => this.sessions;
+
+ /// Starts accepting client connections.
+ public void Start()
+ {
+ if (this.acceptLoopTask != null) return;
+
+ // Make the directory for the socket if it doesn't exist
+ var socketDir = Path.GetDirectoryName(this.SocketPath);
+ if (!string.IsNullOrEmpty(socketDir) && !Directory.Exists(socketDir))
+ {
+ try
+ {
+ Directory.CreateDirectory(socketDir);
+ }
+ catch (Exception ex)
+ {
+ this.log.Error(ex, "Failed to create socket directory: {Path}", socketDir);
+ return;
+ }
+ }
+
+ // Delete existing socket for this PID, if it exists.
+ if (File.Exists(this.SocketPath))
+ {
+ try
+ {
+ File.Delete(this.SocketPath);
+ }
+ catch (Exception ex)
+ {
+ this.log.Warning(ex, "Failed to delete existing socket file: {Path}", this.SocketPath);
+ }
+ }
+
+ this.acceptLoopTask = Task.Factory.StartNew(this.AcceptLoopAsync, TaskCreationOptions.LongRunning);
+
+ // note: needs to be run _after_ we're alive so that we don't delete our own socket.
+ if (this.cleanupSocketDirectory != null)
+ {
+ Task.Run(async () => await UnixSocketUtil.CleanStaleSockets(this.cleanupSocketDirectory));
+ }
+ }
+
+ /// Invoke an RPC request on a specific client expecting a result.
+ /// The client ID to invoke.
+ /// The method to invoke.
+ /// Any arguments to invoke.
+ /// An optional return based on the specified RPC.
+ /// The expected response type.
+ public Task InvokeClientAsync(Guid clientId, string method, params object[] arguments)
+ {
+ if (!this.sessions.TryGetValue(clientId, out var session))
+ throw new KeyNotFoundException($"No client {clientId}");
+
+ return session.Rpc.InvokeAsync(method, arguments);
+ }
+
+ /// Send a notification to all connected clients (no response expected).
+ /// The method name to broadcast.
+ /// The arguments to broadcast.
+ /// Returns a Task when completed.
+ public Task BroadcastNotifyAsync(string method, params object[] arguments)
+ {
+ var list = this.sessions.Values;
+ var tasks = new List(list.Count);
+ foreach (var s in list)
+ {
+ tasks.Add(s.Rpc.NotifyAsync(method, arguments));
+ }
+
+ return Task.WhenAll(tasks);
+ }
+
+ ///
+ public void Dispose()
+ {
+ this.cts.Cancel();
+ this.acceptLoopTask?.Wait(1000);
+
+ foreach (var kv in this.sessions)
+ {
+ kv.Value.Dispose();
+ }
+
+ this.sessions.Clear();
+
+ this.listenSocket?.Dispose();
+
+ if (File.Exists(this.SocketPath))
+ {
+ try
+ {
+ File.Delete(this.SocketPath);
+ }
+ catch (Exception ex)
+ {
+ this.log.Warning(ex, "Failed to delete socket file on dispose: {Path}", this.SocketPath);
+ }
+ }
+
+ this.cts.Dispose();
+ this.log.Information("UnixRpcHost disposed ({Socket})", this.SocketPath);
+ GC.SuppressFinalize(this);
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ this.log.Information("UnixRpcHost starting on socket {Socket}", this.SocketPath);
+ var token = this.cts.Token;
+
+ try
+ {
+ var endpoint = new UnixDomainSocketEndPoint(this.SocketPath);
+ this.listenSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ this.listenSocket.Bind(endpoint);
+ this.listenSocket.Listen(128);
+
+ while (!token.IsCancellationRequested)
+ {
+ Socket? clientSocket = null;
+ try
+ {
+ clientSocket = await this.listenSocket.AcceptAsync(token).ConfigureAwait(false);
+
+ var stream = new NetworkStream(clientSocket, ownsSocket: true);
+ var session = new RpcConnection(stream, this.registry);
+ this.sessions.TryAdd(session.Id, session);
+
+ this.log.Debug("RPC connection created: {Id}", session.Id);
+
+ _ = session.Completion.ContinueWith(t =>
+ {
+ this.sessions.TryRemove(session.Id, out _);
+ this.log.Debug("RPC connection removed: {Id}", session.Id);
+ }, TaskScheduler.Default);
+ }
+ catch (OperationCanceledException)
+ {
+ clientSocket?.Dispose();
+ break;
+ }
+ catch (Exception ex)
+ {
+ clientSocket?.Dispose();
+ this.log.Error(ex, "Error in socket accept loop");
+ await Task.Delay(500, token).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ this.log.Error(ex, "Fatal error in Unix socket accept loop");
+ }
+ }
+}
diff --git a/Dalamud/Plugin/Services/IPluginLinkHandler.cs b/Dalamud/Plugin/Services/IPluginLinkHandler.cs
index 5d2d32728..bff5c8ba2 100644
--- a/Dalamud/Plugin/Services/IPluginLinkHandler.cs
+++ b/Dalamud/Plugin/Services/IPluginLinkHandler.cs
@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
-using Dalamud.Networking.Pipes;
+using Dalamud.Networking.Rpc.Model;
namespace Dalamud.Plugin.Services;
diff --git a/Dalamud/Utility/UnixSocketUtil.cs b/Dalamud/Utility/UnixSocketUtil.cs
new file mode 100644
index 000000000..46bb05c74
--- /dev/null
+++ b/Dalamud/Utility/UnixSocketUtil.cs
@@ -0,0 +1,92 @@
+using System.IO;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+using Serilog;
+
+namespace Dalamud.Utility;
+
+///
+/// A set of utilities to help manage Unix sockets.
+///
+internal static class UnixSocketUtil
+{
+ // Default probe timeout in milliseconds.
+ private const int DefaultProbeMs = 200;
+
+ ///
+ /// Test whether a Unix socket is alive/listening.
+ ///
+ /// The path to test.
+ /// How long to wait for a connection success.
+ /// A task result representing if a socket is alive or not.
+ public static async Task IsSocketAlive(string path, int timeoutMs = DefaultProbeMs)
+ {
+ if (string.IsNullOrEmpty(path)) return false;
+ var endpoint = new UnixDomainSocketEndPoint(path);
+ using var client = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+
+ var connectTask = client.ConnectAsync(endpoint);
+ var completed = await Task.WhenAny(connectTask, Task.Delay(timeoutMs)).ConfigureAwait(false);
+
+ if (completed == connectTask)
+ {
+ // Connected or failed very quickly. If the task is successful, the socket is alive.
+ if (connectTask.IsCompletedSuccessfully)
+ {
+ try
+ {
+ client.Shutdown(SocketShutdown.Both);
+ }
+ catch
+ {
+ // ignored
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Find and remove stale Dalamud RPC sockets.
+ ///
+ /// The directory to scan for stale sockets.
+ /// The timeout to wait for a connection attempt to succeed.
+ /// A task that executes when sockets are purged.
+ public static async Task CleanStaleSockets(string directory, int probeTimeoutMs = DefaultProbeMs)
+ {
+ if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) return;
+
+ foreach (var file in Directory.EnumerateFiles(directory, "DalamudRPC.*.sock", SearchOption.TopDirectoryOnly))
+ {
+ // we don't need to check ourselves.
+ if (file.Contains(Environment.ProcessId.ToString())) continue;
+
+ bool shouldDelete;
+
+ try
+ {
+ shouldDelete = !await IsSocketAlive(file, probeTimeoutMs);
+ }
+ catch
+ {
+ shouldDelete = true;
+ }
+
+ if (shouldDelete)
+ {
+ try
+ {
+ File.Delete(file);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Could not delete stale socket file: {File}", file);
+ }
+ }
+ }
+ }
+}