106 lines
5.2 KiB
C#
106 lines
5.2 KiB
C#
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<string, Task<byte[]>> _handle;
|
|
private readonly Action<Exception> _onError;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
|
|
internal UdsServer(string path, Func<string, Task<byte[]>> handle, Action<Exception> 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();
|
|
}
|
|
}
|