using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Pipes; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; using Newtonsoft.Json.Linq; using XFE.SeAgent.Plugin.Protocol; namespace XFE.SeAgent.Plugin.Transport { /// Local, current-user JSON RPC. Only Pump invokes game-facing callbacks. public sealed class AgentServer : IDisposable { public const int MaximumConnections = 4; public const int MaximumQueuedRequests = 128; private const int ReadTimeoutMs = 10000; private const int WriteTimeoutMs = 5000; private readonly Func execute; private readonly Action log; private readonly object gate = new object(); private readonly Queue requests = new Queue(); private readonly Queue logs = new Queue(); private readonly HashSet pipes = new HashSet(); private readonly CancellationTokenSource shutdown = new CancellationTokenSource(); private int disposed; private int pumpThread; private int pumping; private bool started; public string PipeName { get; } public AgentServer(Func execute, Action log) { this.execute = execute ?? throw new ArgumentNullException(nameof(execute)); this.log = log ?? throw new ArgumentNullException(nameof(log)); PipeName = "XFE.SE.Agent." + Process.GetCurrentProcess().Id; } public void Start() { lock (gate) { ThrowIfDisposed(); if (started) return; var initial = new List(); try { for (int i = 0; i < MaximumConnections; i++) { var pipe = CreatePipe(); initial.Add(pipe); pipes.Add(pipe); } started = true; foreach (var pipe in initial) _ = Task.Run(() => ListenAsync(pipe)); EnqueueLog("Agent pipe ready: " + PipeName); } catch { foreach (var pipe in initial) { pipes.Remove(pipe); pipe.Dispose(); } throw; } } } private NamedPipeServerStream CreatePipe() { using (var identity = WindowsIdentity.GetCurrent()) { var user = identity.User ?? throw new InvalidOperationException("Current Windows user SID is unavailable."); var security = new PipeSecurity(); security.SetAccessRuleProtection(true, false); security.SetOwner(user); security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.NetworkSid, null), PipeAccessRights.FullControl, AccessControlType.Deny)); security.AddAccessRule(new PipeAccessRule(user, PipeAccessRights.FullControl, AccessControlType.Allow)); #if NETFRAMEWORK return new NamedPipeServerStream(PipeName, PipeDirection.InOut, MaximumConnections, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 4096, 4096, security); #else return NamedPipeServerStreamAcl.Create(PipeName, PipeDirection.InOut, MaximumConnections, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 4096, 4096, security); #endif } } private async Task ListenAsync(NamedPipeServerStream first) { NamedPipeServerStream pipe = first; while (Volatile.Read(ref disposed) == 0) { try { await pipe.WaitForConnectionAsync(shutdown.Token).ConfigureAwait(false); await HandleConnectionAsync(pipe).ConfigureAwait(false); } catch (OperationCanceledException) when (Volatile.Read(ref disposed) != 0) { } catch (ObjectDisposedException) when (Volatile.Read(ref disposed) != 0) { } catch (Exception error) { if (Volatile.Read(ref disposed) == 0) EnqueueLog("Agent connection ended: " + error.GetType().Name); } finally { lock (gate) pipes.Remove(pipe); pipe.Dispose(); } lock (gate) { if (disposed != 0) return; try { pipe = CreatePipe(); pipes.Add(pipe); } catch (Exception error) { EnqueueLog("Agent pipe listener stopped: " + error.GetType().Name); return; } } } } private async Task HandleConnectionAsync(NamedPipeServerStream pipe) { AgentRequest request; try { string line; using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token)) { readTimeout.CancelAfter(ReadTimeoutMs); using (readTimeout.Token.Register(() => ClosePipe(pipe))) line = await ReadLineAsync(pipe, readTimeout.Token).ConfigureAwait(false); } request = AgentProtocol.ParseRequest(line); } catch (AgentProtocolException error) { await WriteResponseAsync(pipe, AgentProtocol.Error(error.RequestId, error.Code, error.Message)).ConfigureAwait(false); return; } catch (System.Text.DecoderFallbackException) { await WriteResponseAsync(pipe, AgentProtocol.Error(null, "parse_error", "Request must be valid UTF-8.")).ConfigureAwait(false); return; } var pending = new PendingRequest(request, () => IsClientPresent(pipe), () => Volatile.Read(ref disposed) != 0); lock (gate) { if (disposed != 0) pending.Cancel("server_stopped", "Agent server has stopped."); else if (requests.Count >= MaximumQueuedRequests) pending.Cancel("server_busy", "Request queue is full. Retry later."); else requests.Enqueue(pending); } // One connection carries exactly one request. A second read detects remote close // while the game thread is busy, without a worker touching any game objects. Task disconnected = ObserveDisconnectAsync(pipe, pending); using (var wait = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token)) { Task timeout = Task.Delay(request.TimeoutMs, wait.Token); Task finished = await Task.WhenAny(pending.Completion, disconnected, timeout).ConfigureAwait(false); if (finished == timeout) pending.Cancel(Volatile.Read(ref disposed) == 0 ? "timeout" : "server_stopped", "Request expired before execution."); JObject response = await pending.Completion.ConfigureAwait(false); wait.Cancel(); await WriteResponseAsync(pipe, response).ConfigureAwait(false); } } private static async Task ReadLineAsync(NamedPipeServerStream pipe, CancellationToken token) { var buffer = new byte[4096]; using (var line = new MemoryStream()) { while (true) { int count = await pipe.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false); if (count == 0) throw new EndOfStreamException("Client closed before sending a complete request line."); int newline = Array.IndexOf(buffer, (byte)'\n', 0, count); int length = newline < 0 ? count : newline; if (line.Length + length > AgentProtocol.MaxRequestBytes) throw new AgentProtocolException("request_too_large", "Request exceeds the 1 MiB limit."); line.Write(buffer, 0, length); if (newline < 0) continue; if (newline != count - 1) throw new AgentProtocolException("invalid_request", "Only one request line is allowed per connection."); byte[] data = line.ToArray(); int textLength = data.Length > 0 && data[data.Length - 1] == '\r' ? data.Length - 1 : data.Length; return AgentProtocol.Utf8.GetString(data, 0, textLength); } } } private static async Task ObserveDisconnectAsync(NamedPipeServerStream pipe, PendingRequest pending) { try { var extra = new byte[1]; int count = await pipe.ReadAsync(extra, 0, 1).ConfigureAwait(false); pending.Cancel(count == 0 ? "disconnected" : "invalid_request", count == 0 ? "Client disconnected before completion." : "Only one request line is allowed per connection."); } catch (Exception error) when (error is IOException || error is ObjectDisposedException || error is InvalidOperationException || error is OperationCanceledException) { pending.Cancel("disconnected", "Client disconnected before completion."); } } private async Task WriteResponseAsync(NamedPipeServerStream pipe, JObject response) { byte[] bytes; try { bytes = AgentProtocol.SerializeResponse(response); } catch (AgentRpcException error) { bytes = AgentProtocol.SerializeResponse(AgentProtocol.Error(response["id"], error.Code, error.Message)); } using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token)) { timeout.CancelAfter(WriteTimeoutMs); using (timeout.Token.Register(() => ClosePipe(pipe))) { await pipe.WriteAsync(bytes, 0, bytes.Length, timeout.Token).ConfigureAwait(false); await pipe.FlushAsync(timeout.Token).ConfigureAwait(false); } } } public void Pump(int maxRequests = 4) { if (maxRequests < 0) throw new ArgumentOutOfRangeException(nameof(maxRequests)); if (Volatile.Read(ref disposed) != 0) return; int thread = Thread.CurrentThread.ManagedThreadId; int owner = Interlocked.CompareExchange(ref pumpThread, thread, 0); if (owner != 0 && owner != thread) throw new InvalidOperationException("AgentServer.Pump must remain on the game Update thread."); if (Interlocked.CompareExchange(ref pumping, 1, 0) != 0) return; try { for (int i = 0; i < 8; i++) { string entry; lock (gate) { if (logs.Count == 0) break; entry = logs.Dequeue(); } try { log(entry); } catch { /* Logging must not stop the game update loop. */ } } int executed = 0; for (int examined = 0; examined < MaximumQueuedRequests && executed < Math.Min(maxRequests, MaximumQueuedRequests); examined++) { PendingRequest pending; lock (gate) { if (requests.Count == 0 || disposed != 0) break; pending = requests.Dequeue(); } if (!pending.TryStart()) continue; executed++; try { pending.Complete(AgentProtocol.Success(pending.Request.Id, execute(pending.Request.Method, pending.Request.Parameters))); } catch (AgentRpcException error) { pending.Complete(AgentProtocol.Error(pending.Request.Id, error.Code, error.Message)); } catch (Exception error) { pending.Complete(AgentProtocol.Error(pending.Request.Id, "execution_error", error.Message)); } } } finally { Volatile.Write(ref pumping, 0); } } private void EnqueueLog(string message) { lock (gate) { if (logs.Count >= MaximumQueuedRequests) logs.Dequeue(); logs.Enqueue(message); } } private static bool IsClientPresent(NamedPipeServerStream pipe) { try { uint available; return pipe.IsConnected && PeekNamedPipe(pipe.SafePipeHandle, IntPtr.Zero, 0, IntPtr.Zero, out available, IntPtr.Zero); } catch (Exception error) when (error is IOException || error is ObjectDisposedException || error is InvalidOperationException) { return false; } } [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PeekNamedPipe(SafePipeHandle pipe, IntPtr buffer, uint bufferSize, IntPtr bytesRead, out uint bytesAvailable, IntPtr bytesLeftThisMessage); private static void ClosePipe(NamedPipeServerStream pipe) { try { pipe.Dispose(); } catch (IOException) { } } private void ThrowIfDisposed() { if (Volatile.Read(ref disposed) != 0) throw new ObjectDisposedException(nameof(AgentServer)); } public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; NamedPipeServerStream[] open; lock (gate) { foreach (var pending in requests) pending.Cancel("server_stopped", "Agent server has stopped."); requests.Clear(); open = new NamedPipeServerStream[pipes.Count]; pipes.CopyTo(open); } shutdown.Cancel(); foreach (var pipe in open) ClosePipe(pipe); // The cancellation source stays alive until outstanding async pipe operations finish. } } }