MessagePack is walked into JSON by the reader, so every control character is escaped and keys, order and integers survive. Every contract map (list, entry, record, answer, refusal) is closed and typed; list.mind must equal the call's Mind; a key written twice anywhere, a float, a binary, trailing bytes, or non-canonical base64 is a DeskDoorException, as is every malformed variant (null elements, non-map values). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
197 lines
7.5 KiB
C#
197 lines
7.5 KiB
C#
using System.Buffers;
|
|
using System.Buffers.Binary;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using MessagePack;
|
|
|
|
namespace Bifrost.Web.Tests.Support;
|
|
|
|
// Huginn's desk door as a test double on a real unix socket: it accepts connections, reads each request frame,
|
|
// records it, and lets the test decide what happens next (reply, dribble, stall, close).
|
|
public sealed class FakeHuginnSocket : IDisposable
|
|
{
|
|
private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
|
private readonly CancellationTokenSource stop = new();
|
|
private readonly string directory = Directory.CreateTempSubdirectory("hd").FullName;
|
|
private readonly Func<Socket, byte[], CancellationToken, Task> handler;
|
|
|
|
public FakeHuginnSocket(Func<Socket, byte[], CancellationToken, Task> handler)
|
|
{
|
|
this.handler = handler;
|
|
Path = System.IO.Path.Combine(directory, "d.sock");
|
|
listener.Bind(new UnixDomainSocketEndPoint(Path));
|
|
listener.Listen(16);
|
|
_ = Task.Run(AcceptLoopAsync);
|
|
}
|
|
|
|
public string Path { get; }
|
|
|
|
// The whole request frame of every connection, header included.
|
|
public List<byte[]> Requests { get; } = [];
|
|
|
|
// A path no one listens on.
|
|
public static string Absent() => System.IO.Path.Combine(Directory.CreateTempSubdirectory("hd").FullName, "absent.sock");
|
|
|
|
public static byte[] Fixture(string name) =>
|
|
File.ReadAllBytes(System.IO.Path.Combine(DeskFeedFixturePinTests.Directory, name + ".frame.bin"));
|
|
|
|
// Writes the bytes and closes.
|
|
public static Func<Socket, byte[], CancellationToken, Task> Replay(byte[] frame) =>
|
|
async (socket, _, token) => await socket.SendAsync(frame, SocketFlags.None, token);
|
|
|
|
// Answers each request by its own message id and operation: the reply is built from the request.
|
|
public static Func<Socket, byte[], CancellationToken, Task> Answer(Func<string, JsonDocument, string> variantJson, string status = "accepted") =>
|
|
async (socket, request, token) =>
|
|
{
|
|
using var envelope = DeskWire.Envelope(request);
|
|
var root = envelope.RootElement;
|
|
var operation = root.GetProperty("operation").GetString()!;
|
|
var reply = DeskWire.ResponseFrame(root.GetProperty("messageId").GetString()!, operation, status, variantJson(operation, envelope));
|
|
await socket.SendAsync(reply, SocketFlags.None, token);
|
|
};
|
|
|
|
// Answers each request with a frame the test builds from the request's message id and operation.
|
|
public static Func<Socket, byte[], CancellationToken, Task> Build(Func<string, string, byte[]> frameOf) =>
|
|
async (socket, request, token) =>
|
|
{
|
|
using var envelope = DeskWire.Envelope(request);
|
|
var root = envelope.RootElement;
|
|
await socket.SendAsync(frameOf(root.GetProperty("messageId").GetString()!, root.GetProperty("operation").GetString()!), SocketFlags.None, token);
|
|
};
|
|
|
|
private async Task AcceptLoopAsync()
|
|
{
|
|
while (!stop.IsCancellationRequested)
|
|
{
|
|
Socket client;
|
|
try
|
|
{
|
|
client = await listener.AcceptAsync(stop.Token);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
using (client)
|
|
{
|
|
try
|
|
{
|
|
var header = new byte[4];
|
|
await client.ReceiveAllAsync(header, stop.Token);
|
|
var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)];
|
|
await client.ReceiveAllAsync(body, stop.Token);
|
|
var request = header.Concat(body).ToArray();
|
|
lock (Requests)
|
|
{
|
|
Requests.Add(request);
|
|
}
|
|
|
|
await handler(client, request, stop.Token);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// The client went away, or the test ended.
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
stop.Cancel();
|
|
listener.Dispose();
|
|
try
|
|
{
|
|
Directory.Delete(directory, true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class SocketReceive
|
|
{
|
|
public static async Task ReceiveAllAsync(this Socket socket, byte[] buffer, CancellationToken token)
|
|
{
|
|
for (var read = 0; read < buffer.Length;)
|
|
{
|
|
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, token);
|
|
if (count == 0)
|
|
{
|
|
throw new EndOfStreamException();
|
|
}
|
|
|
|
read += count;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Frames and envelopes as the golden fixtures spell them, for building replies and reading requests.
|
|
public static class DeskWire
|
|
{
|
|
public static JsonDocument Envelope(byte[] frame)
|
|
{
|
|
var reader = new MessagePackReader(frame.AsMemory(4));
|
|
var json = new StringWriter();
|
|
MessagePackSerializer.ConvertToJson(ref reader, json);
|
|
return JsonDocument.Parse(json.ToString());
|
|
}
|
|
|
|
// The base64 payload of an envelope, decoded to JSON.
|
|
public static JsonDocument Payload(JsonDocument envelope)
|
|
{
|
|
var bytes = Convert.FromBase64String(envelope.RootElement.GetProperty("payload").GetString()!);
|
|
var reader = new MessagePackReader(bytes);
|
|
var json = new StringWriter();
|
|
MessagePackSerializer.ConvertToJson(ref reader, json);
|
|
return JsonDocument.Parse(json.ToString());
|
|
}
|
|
|
|
// A reply frame in operation-message key order. `mutate` edits the text fields (a pair can be added twice) before
|
|
// they are written; `trailing` rides inside the frame's one message, after the map.
|
|
public static byte[] ResponseFrame(
|
|
string messageId, string operation, string status, string variantJson, Action<List<(string Key, string Value)>>? mutate = null) =>
|
|
ResponseFrame(messageId, operation, status, MessagePackSerializer.ConvertFromJson(variantJson), mutate);
|
|
|
|
public static byte[] ResponseFrame(
|
|
string messageId, string operation, string status, byte[] payload, Action<List<(string Key, string Value)>>? mutate = null, byte[]? trailing = null)
|
|
{
|
|
var fields = new List<(string Key, string Value)>
|
|
{
|
|
("messageId", messageId),
|
|
("operation", operation),
|
|
("payload", Convert.ToBase64String(payload)),
|
|
("payloadEncoding", "messagepack-base64"),
|
|
("payloadSchema", "huginn.desk_response.v1"),
|
|
("schemaVersion", "cultnet.operation_response.v0"),
|
|
("serviceId", "huginn.desk"),
|
|
("sourceRuntimeId", "huginn-test"),
|
|
("status", status),
|
|
};
|
|
mutate?.Invoke(fields);
|
|
var buffer = new ArrayBufferWriter<byte>();
|
|
var w = new MessagePackWriter(buffer);
|
|
w.WriteMapHeader(fields.Count);
|
|
foreach (var (key, value) in fields)
|
|
{
|
|
w.Write(key);
|
|
w.Write(value);
|
|
}
|
|
|
|
w.Flush();
|
|
return Frame([.. buffer.WrittenSpan, .. trailing ?? []]);
|
|
}
|
|
|
|
public static byte[] Frame(ReadOnlySpan<byte> message)
|
|
{
|
|
var frame = new byte[4 + message.Length];
|
|
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
|
|
message.CopyTo(frame.AsSpan(4));
|
|
return frame;
|
|
}
|
|
}
|