GameCult.Thing.Contract: every empty memory is an empty RecordPayload; wire nil is refused on read

Present(ReadOnlyMemory<byte>) is deleted. The constructor copies any view, default
included, as an empty payload; a nil on the wire (which decodes to a default view) is
noted by the constructor and refused in OnAfterDeserialize, so the nil-positions rows hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 04:53:40 +02:00
commit e25311a0c0
3 changed files with 43 additions and 15 deletions

View file

@ -104,16 +104,32 @@ public sealed class CommandDecl
}
}
/// <summary>
/// A typed record as its MessagePack bytes. Every empty memory (<c>ReadOnlyMemory&lt;byte&gt;.Empty</c>, a default view,
/// an empty array) is an empty payload, as Rust's empty <c>Vec&lt;u8&gt;</c> is. A nil on the wire is not an empty
/// payload: it decodes to a default view the constructor cannot tell from an empty one, so the constructor notes it and
/// the decode door refuses it once the value is read.
/// </summary>
[MessagePackObject]
public sealed class RecordPayload
public sealed class RecordPayload : IMessagePackSerializationCallbackReceiver
{
private readonly bool _viewWasDefault;
[Key(0)] public string Schema { get; }
[Key(1)] public ReadOnlyMemory<byte> Bytes { get; }
public RecordPayload(string schema, ReadOnlyMemory<byte> bytes)
{
Schema = ThingDomain.Present(schema, "Schema");
Bytes = ThingDomain.Present(bytes, "Bytes").ToArray();
_viewWasDefault = bytes.Equals(default(ReadOnlyMemory<byte>));
Bytes = bytes.ToArray();
}
void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize() { }
void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
{
if (_viewWasDefault) throw new ThingDomainException("Bytes", "present");
}
}

View file

@ -49,15 +49,6 @@ public static class ThingDomain
public static T Present<T>(T? value, string slot) where T : class =>
value ?? throw new ThingDomainException(slot, "present");
/// <summary>
/// A required byte slot. A null on the wire decodes to a default view with no backing store, so the default view is
/// refused as nil; an empty array is a present, empty payload.
/// </summary>
public static ReadOnlyMemory<byte> Present(ReadOnlyMemory<byte> value, string slot) =>
value.Equals(default(ReadOnlyMemory<byte>))
? throw new ThingDomainException(slot, "present")
: value;
/// <summary>
/// A required list slot: a read-only copy, so the caller's list cannot change the value afterwards. A null list or
/// a null item is refused naming the slot.

View file

@ -1,3 +1,4 @@
using MessagePack;
using System.Collections;
using System.Reflection;
@ -165,11 +166,31 @@ public sealed class ReadOnlyListTests
}
[Test]
public void ANilPayloadIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
public void EveryEmptyMemoryIsAnEmptyPayloadThatWritesAsRustsEmptyVec()
{
var error = Assert.Throws<ThingDomainException>(() => new RecordPayload("s", default))!;
Assert.That(error.Domain, Is.EqualTo("Bytes"));
Assert.That(error.Bound, Is.EqualTo("present"));
// Rust's empty Vec<u8> is bin8 of length 0 (c4 00); a RecordPayload is [schema, bytes].
var rust = new byte[] { 0x92, 0xA1, (byte)'s', 0xC4, 0x00 };
var empties = new Dictionary<string, ReadOnlyMemory<byte>>
{
["ReadOnlyMemory.Empty"] = ReadOnlyMemory<byte>.Empty,
["Memory.Empty"] = Memory<byte>.Empty,
["default"] = default(ReadOnlyMemory<byte>),
["Array.Empty"] = Array.Empty<byte>(),
["new byte[0]"] = new byte[0],
};
foreach (var (name, memory) in empties)
{
var payload = new RecordPayload("s", memory);
Assert.That(payload.Bytes.Length, Is.EqualTo(0), name);
Assert.That(Wire.Write(payload), Is.EqualTo(rust), name);
Assert.That(Wire.Write(Wire.Read(typeof(RecordPayload), rust)), Is.EqualTo(rust), name);
}
}
[Test]
public void ANilPayloadOnTheWireIsRefusedAndAnEmptyOneIsKeptThroughTheWire()
{
Assert.Throws<MessagePackSerializationException>(() => Wire.Read(typeof(RecordPayload), new byte[] { 0x92, 0xA1, (byte)'s', 0xC0 }));
var empty = new CommandInvocation("i", "k", 1, "go", new RecordPayload("s", Array.Empty<byte>()), new List<CapturedInput>(), "c", "t", null);
var decoded = (CommandInvocation)Wire.Read(typeof(CommandInvocation), Wire.Write(empty));