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;
/// <summary>Exercises the native wire protocol using isolated local pipes, without loading a game or DLL.</summary>
[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<JsonElement> 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<JsonElement>? 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<T> RejectsAsync<T>(Func<Task> 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<ArgumentOutOfRangeException>(() => new NavigationPipeClient(id));
foreach (var service in new[] { "", "Plugin", "navigation", "Navigation.1", "../Navigation" })
Check.Throws<ArgumentException>(() => 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<InvalidOperationException>(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<InvalidDataException>(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<JsonException>(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<InvalidDataException>(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<InvalidDataException>(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<OperationCanceledException>(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<OperationCanceledException>(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;
}
}
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;
/// <summary>Exercises the native wire protocol using isolated local pipes, without loading a game or DLL.</summary>
[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<JsonElement> 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<JsonElement>? 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<T> RejectsAsync<T>(Func<Task> 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<ArgumentOutOfRangeException>(() => new NavigationPipeClient(id));
foreach (var service in new[] { "", "Plugin", "navigation", "Navigation.1", "../Navigation" })
Check.Throws<ArgumentException>(() => 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<InvalidOperationException>(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<InvalidDataException>(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<JsonException>(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<InvalidDataException>(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<InvalidDataException>(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<OperationCanceledException>(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<OperationCanceledException>(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;
}
}