using System.IO.Pipes; using System.Text; using System.Text.Json; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; /// Exercises the native wire protocol using isolated local pipes, without loading a game or DLL. [TestFixture] public class NativeInterfaceContractTests { private static int NextProcessId() => Random.Shared.Next(200_000_000, int.MaxValue); private static NamedPipeServerStream CreateServer(int processId, string service = "Navigation") => new($"XFEEDToolBox.{service}.{processId}", PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); private static async Task ReadRequestAsync(NamedPipeServerStream server, CancellationToken token) { using var request = new MemoryStream(); var buffer = new byte[4096]; while (request.Length < 65536) { var count = await server.ReadAsync(buffer, token); if (count == 0) throw new InvalidDataException("The client disconnected before completing its request."); request.Write(buffer, 0, count); try { using var parsed = JsonDocument.Parse(request.GetBuffer().AsMemory(0, (int)request.Length)); return parsed.RootElement.Clone(); } catch (JsonException) { } } throw new InvalidDataException("The client sent an oversized request."); } private static async Task ServeAsync(NamedPipeServerStream server, byte[] response, CancellationToken token, Action? inspect = null, int fragmentSize = int.MaxValue) { await server.WaitForConnectionAsync(token); var request = await ReadRequestAsync(server, token); inspect?.Invoke(request); for (var offset = 0; offset < response.Length; offset += Math.Min(fragmentSize, response.Length - offset)) { var count = Math.Min(fragmentSize, response.Length - offset); await server.WriteAsync(response.AsMemory(offset, count), token); await server.FlushAsync(token); } if (response.Length == 0) return; // Keep the server alive until the client consumes the reply and closes its end. Check.Equal(0, await server.ReadAsync(new byte[1], token)); } private static async Task RejectsAsync(Func action) where T : Exception { try { await action(); } catch (T error) { return error; } throw new InvalidOperationException("Expected " + typeof(T).Name); } [Test] public void InvalidProcessAndUnknownServiceCannotCreatePipeClients() { foreach (var id in new[] { 0, -1, int.MinValue }) Check.Throws(() => new NavigationPipeClient(id)); foreach (var service in new[] { "", "Plugin", "navigation", "Navigation.1", "../Navigation" }) Check.Throws(() => new NavigationPipeClient(1, service)); } [Test] public async Task FragmentedUtf8ResponsesPreserveExactIntegerValuesOnBothServices() { foreach (var service in new[] { "Navigation", "Interaction" }) { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var server = CreateServer(id, service); const long address = 9007199254740995; const string title = "阿尔法星系 🚀"; var wire = Encoding.UTF8.GetBytes("{\"ok\":true,\"result\":{\"title\":\"" + title + "\",\"address\":9007199254740995,\"entityId\":\"18446744073709551615\"}}\n"); var serving = ServeAsync(server, wire, timeout.Token, request => { Check.Equal("describe", request.GetProperty("op").GetString()); Check.Equal(address, request.GetProperty("targetSystemAddress").GetInt64()); }, fragmentSize: 1); var result = await new NavigationPipeClient(id, service) .CallAsync(new { op = "describe", targetSystemAddress = address }, timeout.Token); await serving; // Returned JSON must remain usable after the client's JsonDocument is disposed. Check.Equal(title, result.GetProperty("title").GetString()); Check.Equal(address, result.GetProperty("address").GetInt64()); Check.Equal(ulong.MaxValue, ulong.Parse(result.GetProperty("entityId").GetString()!)); } } [Test] public async Task RejectedRepliesPreserveNativeErrorAndAllowFollowingRequests() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = new NavigationPipeClient(id); foreach (var envelope in new[] { "{\"ok\":false,\"error\":\"Game route planning requires a fresh idle FSD\"}\n", "{\"error\":\"Missing protocol acknowledgement\"}\n", "{\"ok\":\"true\",\"error\":\"Invalid acknowledgement type\"}\n" }) { await using var server = CreateServer(id); var serving = ServeAsync(server, Encoding.UTF8.GetBytes(envelope), timeout.Token); var error = await RejectsAsync(async () => await client.CallAsync(new { op = "describe" }, timeout.Token)); using var expected = JsonDocument.Parse(envelope); Check.Equal(expected.RootElement.GetProperty("error").GetString(), error.Message); await serving; } await using var recovery = CreateServer(id); var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token); Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean()); await recovered; } [Test] public async Task MaximumNavigationRequestRetainsItsByteBoundary() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = new NavigationPipeClient(id); var overhead = JsonSerializer.SerializeToUtf8Bytes(new { op = "describe", padding = "" }, ToolboxSettings.JsonOptions).Length; var largest = new { op = "describe", padding = new string('x', 65534 - overhead) }; Check.Equal(65534, JsonSerializer.SerializeToUtf8Bytes(largest, ToolboxSettings.JsonOptions).Length); await using var server = CreateServer(id); var serving = ServeAsync(server, "{\"ok\":true,\"result\":{}}\n"u8.ToArray(), timeout.Token, request => Check.Equal(largest.padding.Length, request.GetProperty("padding").GetString()!.Length)); await client.CallAsync(largest, timeout.Token); await serving; // An oversized request is rejected locally, before any connection is required. foreach (var length in new[] { 65535, 65536 }) await RejectsAsync(async () => await client.CallAsync( new { op = "describe", padding = new string('x', length - overhead) }, timeout.Token)); } [Test] public async Task MalformedJsonReplyDoesNotPoisonTheNextConnection() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = new NavigationPipeClient(id); await using (var malformed = CreateServer(id)) { var serving = ServeAsync(malformed, "{\"ok\":true,\"result\":\n"u8.ToArray(), timeout.Token); await RejectsAsync(async () => await client.CallAsync(new { op = "describe" }, timeout.Token)); await serving; } await using var recovery = CreateServer(id); var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token); Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean()); await recovered; } [Test] public async Task ResponseByteLimitRejectsFullBufferAndAcceptsThePreviousByte() { foreach (var size in new[] { 65535, 65536 }) { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var server = CreateServer(id); const string prefix = "{\"ok\":true,\"result\":{\"padding\":\""; const string suffix = "\"}}\n"; var wire = Encoding.UTF8.GetBytes(prefix + new string('x', size - prefix.Length - suffix.Length) + suffix); Check.Equal(size, wire.Length); var serving = ServeAsync(server, wire, timeout.Token, fragmentSize: 1024); var client = new NavigationPipeClient(id); if (size == 65535) Check.Equal(size - prefix.Length - suffix.Length, (await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("padding").GetString()!.Length); else await RejectsAsync(async () => await client.CallAsync(new { op = "describe" }, timeout.Token)); await serving; } } [Test] public async Task EmptyReplyIsRejectedWithoutPoisoningTheNextConnection() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = new NavigationPipeClient(id); await using (var empty = CreateServer(id)) { var serving = ServeAsync(empty, [], timeout.Token); var call = client.CallAsync(new { op = "describe" }, timeout.Token); await serving; await empty.DisposeAsync(); await RejectsAsync(async () => await call); } await using var recovery = CreateServer(id); var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"sequence\":1}}\n"u8.ToArray(), timeout.Token); Check.Equal(1, (await client.CallAsync(new { op = "read" }, timeout.Token)).GetProperty("sequence").GetInt32()); await recovered; } [Test] public async Task CancelledQueuedRequestDoesNotCancelTheActiveRequestOrConsumeItsReply() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var queuedCancellation = new CancellationTokenSource(); var client = new NavigationPipeClient(id); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using (var first = CreateServer(id)) { async Task ServeFirstAsync() { await first.WaitForConnectionAsync(timeout.Token); Check.Equal("read", (await ReadRequestAsync(first, timeout.Token)).GetProperty("op").GetString()); received.SetResult(); await release.Task.WaitAsync(timeout.Token); await first.WriteAsync("{\"ok\":true,\"result\":{\"sequence\":1}}\n"u8.ToArray(), timeout.Token); Check.Equal(0, await first.ReadAsync(new byte[1], timeout.Token)); } var serving = ServeFirstAsync(); var active = client.CallAsync(new { op = "read" }, timeout.Token); await received.Task.WaitAsync(timeout.Token); var queued = client.CallAsync(new { op = "describe" }, queuedCancellation.Token); queuedCancellation.Cancel(); await RejectsAsync(async () => await queued); Check.That(!active.IsCompleted, "Cancelling a waiting caller must not terminate the current pipe exchange."); release.SetResult(); Check.Equal(1, (await active).GetProperty("sequence").GetInt32()); await serving; } await using var next = CreateServer(id); var nextServing = ServeAsync(next, "{\"ok\":true,\"result\":{\"sequence\":2}}\n"u8.ToArray(), timeout.Token); Check.Equal(2, (await client.CallAsync(new { op = "read" }, timeout.Token)).GetProperty("sequence").GetInt32()); await nextServing; } [Test] public async Task CallerCancellationDuringReplyReleasesPipeAndSerializationGate() { var id = NextProcessId(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var cancellation = new CancellationTokenSource(); var client = new NavigationPipeClient(id); await using (var blocked = CreateServer(id)) { var connected = blocked.WaitForConnectionAsync(timeout.Token); var call = client.CallAsync(new { op = "describe" }, cancellation.Token); await connected; await ReadRequestAsync(blocked, timeout.Token); cancellation.Cancel(); await RejectsAsync(async () => await call); Check.Equal(0, await blocked.ReadAsync(new byte[1], timeout.Token)); } await using var recovery = CreateServer(id); var serving = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token); Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean()); await serving; } }