using System.Net.Sockets; using System.Text; namespace Outnumbered.Engine; // Minimal request/response server on a Unix domain socket: one verb line in, one payload out, connection closes. // Runs entirely on background threads — the handler must NEVER touch game state (Api.cs serves pre-serialized bytes, // and the one async verb is DB-only). Lifecycle: the ctor binds (delete-before-bind clears a crash leftover; bind // failures throw to the caller, who degrades gracefully), Dispose cancels the accept loop, closes the listener and // unlinks the socket file — a hot-reload (Unload then Load in-process) can then re-bind the same path. internal sealed class UdsServer : IDisposable { private const int MaxRequestBytes = 256; // a verb line; anything bigger is not our client private static readonly TimeSpan IoDeadline = TimeSpan.FromSeconds(2); private readonly Socket _listener; private readonly string _path; private readonly Func> _handle; private readonly Action _onError; private readonly CancellationTokenSource _cts = new(); internal UdsServer(string path, Func> handle, Action onError) { _path = path; _handle = handle; _onError = onError; File.Delete(path); // a crash leaves the old socket file behind, and bind fails on an existing path _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); _listener.Bind(new UnixDomainSocketEndPoint(path)); _listener.Listen(16); // 0660: owner (the game user) + group (shared with the site user) — never world-accessible. Unix-only API, // guarded so a Windows dev build compiles clean (there the socket just keeps default ACLs). if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite); _ = Task.Run(AcceptLoop); } private async Task AcceptLoop() { while (!_cts.IsCancellationRequested) { Socket conn; try { conn = await _listener.AcceptAsync(_cts.Token); } catch (OperationCanceledException) { return; } // Dispose catch (ObjectDisposedException) { return; } // Dispose raced the accept catch (Exception ex) { // A persistent accept fault (fd exhaustion) would otherwise re-throw instantly forever — one retry per // second turns a pegged core on the game host into a single syscall/s until the fault clears. _onError(ex); try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token); } catch (OperationCanceledException) { return; } continue; } _ = Task.Run(() => Serve(conn)); } } // One connection = one verb line -> one payload. Deadlined so a stuck client can never pin resources; errors are // reported (throttled by the caller) and the connection just drops — a game server must never care. private async Task Serve(Socket conn) { using (conn) { try { // Inside the try: _cts.Token throws ObjectDisposedException when Dispose() wins the race against a // queued Serve task (hot-reload while a client connects) — treated like the AcceptLoop's same race. using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); deadline.CancelAfter(IoDeadline); var buf = new byte[MaxRequestBytes]; int len = 0; while (len < MaxRequestBytes) { int n = await conn.ReceiveAsync(buf.AsMemory(len, MaxRequestBytes - len), SocketFlags.None, deadline.Token); if (n == 0) break; // peer closed without a newline len += n; if (Array.IndexOf(buf, (byte)'\n', 0, len) >= 0) break; } int nl = Array.IndexOf(buf, (byte)'\n', 0, len); if (nl < 0) return; // no complete verb line within the cap — not our client, drop silently byte[] payload = await _handle(Encoding.ASCII.GetString(buf, 0, nl).Trim()); int sent = 0; while (sent < payload.Length) { int n = await conn.SendAsync(payload.AsMemory(sent), SocketFlags.None, deadline.Token); if (n <= 0) break; sent += n; } } catch (OperationCanceledException) { /* deadline hit or server shutting down — drop the connection */ } catch (ObjectDisposedException) { /* Dispose raced a queued connection — drop it */ } catch (Exception ex) { _onError(ex); } } } public void Dispose() { _cts.Cancel(); try { _listener.Dispose(); } catch (Exception) { /* already closed */ } try { File.Delete(_path); } catch (Exception) { /* unlink is best-effort; delete-before-bind covers leftovers */ } _cts.Dispose(); } }