feat: integrate performance upgrade and agent inspection tooling
Validate ShrinkSDK Workspace / catalog (push) Successful in 5s
Validate ShrinkSDK Workspace / unity (push) Successful in 3m20s

This commit is contained in:
2026-09-29 10:16:02 +08:00
parent f92105c97d
commit 08246ddb01
114 changed files with 7135 additions and 87 deletions
@@ -0,0 +1,22 @@
using System.Runtime.CompilerServices;
using NUnit.Framework;
using ShrinkCommand;
using ShrinkCommand.Integration;
using ShrinkNetwork;
public class BridgeRetentionTests
{
[Test]
public void CommandBridgeDoesNotKeepRetiredNetworkServiceAlive()
{
var weak = Register(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
Assert.That(weak.IsAlive, Is.False);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference Register()
{
var service = new ShrinkNetworkService();
ShrinkNetworkCommandBridge.RegisterService(service, new ShrinkCommandService());
return new WeakReference(service);
}
}
@@ -0,0 +1,132 @@
using System.Buffers;
using System.Buffers.Binary;
using Cysharp.Threading.Tasks;
using MessagePack;
using NUnit.Framework;
using ShrinkNetwork;
using ShrinkNetwork.MessagePack;
[MessagePackObject]
public partial class CodecMessage : IShrinkNetworkMessage
{
[Key(0)] public int Value { get; set; }
[Key(1)] public string Text { get; set; } = "";
}
[GeneratedMessagePackResolver]
public partial class TestMessageResolver { }
public class NetworkCodecTests
{
[TestCase(false)] [TestCase(true)]
public void EnvelopeRoundTripsPayloadWithoutEncodingItAgain(bool messagePack)
{
IShrinkNetworkBufferSerializer serializer;
if (messagePack) { var mp = new ShrinkMessagePackNetworkSerializer(TestMessageResolver.Instance); mp.Register<CodecMessage>(); serializer = mp; }
else serializer = new ShrinkJsonNetworkSerializer();
var counting = new CountingSerializer(serializer);
var message = new CodecMessage { Value = 42, Text = "跨宿主消息" };
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Opcode = 123, Route = "中文/route", SessionToken = "secret", RequestToken = new(9) }, counting, message);
var packet = ShrinkPacketCodec.Decode(encoded.WrittenMemory);
var decoded = (CodecMessage)serializer.Deserialize(packet.Payload, typeof(CodecMessage));
Assert.That(counting.Count, Is.EqualTo(1));
Assert.That(decoded.Text, Is.EqualTo(message.Text));
Assert.That(decoded.Value, Is.EqualTo(42));
Assert.That(packet.Route, Is.EqualTo("中文/route"));
Assert.That(packet.SessionToken, Is.EqualTo("secret"));
Assert.That(packet.RequestToken.Value, Is.EqualTo(9));
Assert.That(encoded.WrittenCount, Is.EqualTo(ShrinkPacketCodec.HeaderSize + System.Text.Encoding.UTF8.GetByteCount(packet.Route!) + 6 + packet.Payload.Length));
}
[Test]
public void RejectsOldVersionMalformedLengthsAndTrailingData()
{
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(System.Text.Encoding.UTF8.GetBytes("{\"ProtocolVersion\":1}")));
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Payload = new byte[] { 1, 2, 3 } });
var bytes = encoded.WrittenMemory.ToArray();
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(4), 1);
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(4), 2);
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(29), int.MaxValue);
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(encoded.WrittenMemory[..^1]));
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(encoded.WrittenMemory.ToArray().Concat(new byte[1]).ToArray()));
}
[Test]
public void InvalidUtf8HeaderIsAProtocolRejection()
{
using var encoded = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Route = "r", SessionToken = "t" });
foreach (var offset in new[] { ShrinkPacketCodec.HeaderSize, ShrinkPacketCodec.HeaderSize + 1 })
{
var bytes = encoded.WrittenMemory.ToArray(); bytes[offset] = 0xff;
Assert.Throws<ShrinkProtocolException>(() => ShrinkPacketCodec.Decode(bytes));
}
}
[Test]
public void BorrowedDecodeAndWriterDisposeHaveExplicitLifetimes()
{
var writer = ShrinkPacketCodec.Encode(new ShrinkNetworkPacket { Payload = new byte[] { 7 } });
var packet = ShrinkPacketCodec.Decode(writer.WrittenMemory);
Assert.That(packet.Payload.Span[0], Is.EqualTo(7));
writer.Dispose(); writer.Dispose();
Assert.Throws<ObjectDisposedException>(() => writer.GetMemory());
}
[TestCase(false)] [TestCase(true)]
public async Task ServiceKeepsBufferAliveUntilTransportCompletes(bool fail)
{
var transport = new DeferredTransport();
var service = new ShrinkNetworkService();
service.RegisterMessage<CodecMessage>(1);
service.BindTransport(transport);
transport.Connect();
var sending = service.SendAsync(service.Sessions[1], new CodecMessage { Text = "alive" }).AsTask();
Assert.That(sending.IsCompleted, Is.False);
Assert.That(((CodecMessage)new ShrinkJsonNetworkSerializer().Deserialize(ShrinkPacketCodec.Decode(transport.Memory).Payload, typeof(CodecMessage))).Text, Is.EqualTo("alive"));
if (fail) transport.Completion.TrySetException(new IOException("disconnect")); else transport.Completion.TrySetResult();
if (fail) Assert.ThrowsAsync<IOException>(async () => await sending); else await sending;
Assert.That(transport.Memory.Span.ToArray(), Is.All.EqualTo(0), "pooled buffer is cleared and released after completion/failure");
}
[Test]
public async Task QueuedLoopbackReceiverOwnsPacketAfterSenderReturns()
{
using var queue = new ShrinkNetworkDispatchQueue(4);
var client = new ShrinkNetworkService();
var server = new ShrinkNetworkService { DispatchScheduler = queue };
client.RegisterMessage<CodecMessage>(1); server.RegisterMessage<CodecMessage>(1);
var received = "";
server.RegisterHandler<CodecMessage>((ctx, message) => { received = message.Text; return UniTask.CompletedTask; });
var left = new ShrinkLoopbackTransport(); var right = new ShrinkLoopbackTransport(); left.LinkPeer(right);
client.BindTransport(left); server.BindTransport(right); left.OpenSession(1);
await client.SendAsync(client.Sessions[1], new CodecMessage { Text = "retained" });
Assert.That(received, Is.Empty);
await queue.PumpAsync(4);
Assert.That(received, Is.EqualTo("retained"));
}
private sealed class CountingSerializer(IShrinkNetworkBufferSerializer inner) : IShrinkNetworkBufferSerializer
{
public int Count;
public void Serialize(IBufferWriter<byte> writer, object value) { Count++; inner.Serialize(writer, value); }
public object Deserialize(ReadOnlyMemory<byte> payload, Type type) => inner.Deserialize(payload, type);
public byte[] Serialize(object value) => throw new AssertionException("Legacy allocation path used");
public object Deserialize(byte[] payload, Type type) => inner.Deserialize(payload, type);
public T Deserialize<T>(byte[] payload) => inner.Deserialize<T>(payload);
}
private sealed class DeferredTransport : IShrinkNetworkMemoryTransport
{
public bool IsStarted { get; private set; }
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
public UniTaskCompletionSource Completion = new();
public ReadOnlyMemory<byte> Memory;
public void Start() => IsStarted = true;
public void Stop() => IsStarted = false;
public void Connect() => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(1, "test"));
public void Send(long id, byte[] data) => throw new AssertionException("Legacy transport used");
public UniTask SendAsync(long id, byte[] data) => throw new AssertionException("Legacy transport used");
public UniTask SendAsync(long id, ReadOnlyMemory<byte> data, CancellationToken ct = default) { Memory = data; return Completion.Task; }
}
}
@@ -0,0 +1,66 @@
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkNetwork;
public class NetworkQueueTests
{
[Test]
public async Task StateReplacementIsScopedToSessionAndMovesAfterReliableWork()
{
using var queue = new ShrinkNetworkWorkQueue(10, 100);
var output = new List<int>();
UniTask Write(int i) { output.Add(i); return UniTask.CompletedTask; }
var old = queue.EnqueueAsync(1, "position", 10, () => Write(1), "player");
var reliable = queue.EnqueueAsync(1, "position", 10, () => Write(2));
var other = queue.EnqueueAsync(2, "position", 10, () => Write(3), "player");
var latest = queue.EnqueueAsync(1, "position", 20, () => Write(4), "player");
Assert.That(await old, Is.EqualTo(ShrinkNetworkQueueResult.Replaced));
Assert.That(queue.CaptureDiagnostics().PendingBytes, Is.EqualTo(40));
Assert.That(await queue.PumpAsync(10), Is.EqualTo(3));
Assert.That(output, Is.EqualTo(new[] { 2, 3, 4 }));
foreach (var result in new[] { await reliable, await other, await latest }) Assert.That(result, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
}
[Test]
public async Task CapacityRejectionDoesNotLoseExistingReliableWorkOrState()
{
using var queue = new ShrinkNetworkWorkQueue(2, 20, 1);
var a = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask, "x");
Assert.That(await queue.EnqueueAsync(1, "a", 1, () => UniTask.CompletedTask), Is.EqualTo(ShrinkNetworkQueueResult.Rejected));
Assert.That(await queue.EnqueueAsync(1, "a", 21, () => UniTask.CompletedTask, "x"), Is.EqualTo(ShrinkNetworkQueueResult.Rejected));
await queue.PumpAsync(1);
Assert.That(await a, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
Assert.That(queue.CaptureDiagnostics().PendingBytes, Is.Zero);
}
[Test]
public async Task BudgetDoesNotPreemptHandlerAndConcurrentPumpsDoNotRunHandlersInParallel()
{
using var queue = new ShrinkNetworkWorkQueue(4, 100);
var completion = new UniTaskCompletionSource();
var first = queue.EnqueueAsync(1, "a", 10, () => completion.Task);
var second = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask);
var pump = queue.PumpAsync(4, 100, TimeSpan.FromMilliseconds(1)).AsTask();
await Task.Delay(10);
Assert.That(pump.IsCompleted, Is.False);
Assert.That(await queue.PumpAsync(4), Is.Zero);
completion.TrySetResult();
Assert.That(await pump, Is.EqualTo(1));
Assert.That(await first, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
queue.Dispose();
Assert.That(await second, Is.EqualTo(ShrinkNetworkQueueResult.Canceled));
}
[Test]
public async Task ByteBudgetMakesProgressForLargePacketsAndHandlerFailureDoesNotLeakQueue()
{
using var queue = new ShrinkNetworkWorkQueue(4, 100);
var failed = queue.EnqueueAsync(1, "a", 30, () => UniTask.FromException(new IOException("handler"))).AsTask();
var next = queue.EnqueueAsync(1, "a", 10, () => UniTask.CompletedTask);
Assert.That(await queue.PumpAsync(4, 1), Is.EqualTo(1));
Assert.ThrowsAsync<IOException>(async () => await failed);
Assert.That(await queue.PumpAsync(4), Is.EqualTo(1));
Assert.That(await next, Is.EqualTo(ShrinkNetworkQueueResult.Completed));
Assert.That(queue.CaptureDiagnostics().PendingCount, Is.Zero);
}
}
@@ -0,0 +1,70 @@
using System.Net;
using System.Net.Sockets;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkNetwork;
[NonParallelizable]
public class NetworkTransportTests
{
[Test]
public async Task TcpRpcRoundTripAndCanceledSendPreserveNextFrame()
{
var reservation = new TcpListener(IPAddress.Loopback, 0); reservation.Start();
var port = ((IPEndPoint)reservation.LocalEndpoint).Port; reservation.Stop();
var serverTransport = new ShrinkTcpServerTransport(IPAddress.Loopback, port);
var clientTransport = new ShrinkTcpClientTransport("127.0.0.1", port);
var server = Service(); var client = Service();
var connected = new TaskCompletionSource<ShrinkNetworkSession>(TaskCreationOptions.RunContinuationsAsynchronously);
client.OnSessionConnected += session => connected.TrySetResult(session);
server.RegisterRequestHandler<Request, Response>((_, request) => UniTask.FromResult(new Response { Text = request.Text }));
try
{
server.BindTransport(serverTransport); client.BindTransport(clientTransport);
var session = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5));
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
Assert.CatchAsync<OperationCanceledException>(async () => await clientTransport.SendAsync(1, new ReadOnlyMemory<byte>(new byte[100]), cancellation.Token));
var reply = await client.CallAsync<Request, Response>(session, new Request { Text = new string('t', 32768) }).AsTask().WaitAsync(TimeSpan.FromSeconds(5));
Assert.That(reply.Text.Length, Is.EqualTo(32768));
Assert.That(client.GetDiagnosticsSnapshot().RpcCompleted, Is.EqualTo(1));
}
finally { clientTransport.Stop(); serverTransport.Stop(); }
}
[Test]
public async Task NestedRpcResponseBypassesSerialHandlerQueue()
{
var a = new ShrinkLoopbackTransport(); var b = new ShrinkLoopbackTransport(); a.LinkPeer(b);
var client = Service(); var server = Service();
client.RegisterRequestHandler<Request, Response>((_, req) => UniTask.FromResult(new Response { Text = "nested:" + req.Text }));
server.RegisterRequestHandler<Request, Response>(async (ctx, req) => await server.CallAsync<Request, Response>(ctx.Session, req));
client.BindTransport(a); server.BindTransport(b); a.OpenSession(1);
var response = await client.CallAsync<Request, Response>(client.Sessions[1], new Request { Text = "ok" }).AsTask().WaitAsync(TimeSpan.FromSeconds(3));
Assert.That(response.Text, Is.EqualTo("nested:ok"));
a.Stop(); b.Stop();
}
[Test]
public async Task CongestionDisconnectsReliablePeerAndDoesNotResurrectSession()
{
var a = new ShrinkLoopbackTransport(); var b = new ShrinkLoopbackTransport(); a.LinkPeer(b);
using var queue = new ShrinkNetworkDispatchQueue(1, byteCapacity: 1024);
var client = Service(); var server = Service(); server.DispatchScheduler = queue;
client.BindTransport(a); server.BindTransport(b); a.OpenSession(1);
var session = client.Sessions[1];
var rejected = 0; server.OnDispatchRejected += _ => rejected++;
await client.SendAsync(session, new Request());
await client.SendAsync(session, new Request());
Assert.That(rejected, Is.EqualTo(1));
Assert.That(server.Sessions, Is.Empty);
await queue.PumpAsync(1);
Assert.That(server.Sessions, Is.Empty);
a.Stop(); b.Stop();
}
private static ShrinkNetworkService Service()
{
var service = new ShrinkNetworkService(); service.RegisterMessage<Request>(1); service.RegisterMessage<Response>(2); return service;
}
public sealed class Request : IShrinkNetworkRequest { public string Text { get; set; } = ""; }
public sealed class Response : ShrinkRpcResponseBase { public string Text { get; set; } = ""; }
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="UniTask" Version="2.5.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Kcp-CSharp" Version="1.0.8" />
<PackageReference Include="MessagePack" Version="3.1.8" />
<Compile Include="../../../Assets/Modules/ShrinkNetwork/Adapters~/MessagePack/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkRuntime.Abstractions/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkContext.Core/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkContext.Core/Tests/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkNetwork/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkEventBus/Runtime/**/*.cs" Exclude="../../../Assets/Modules/ShrinkEventBus/Runtime/ShrinkMonoEventScope.cs" />
<Compile Include="../../../Assets/Modules/ShrinkCommand/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkCommand.Integration.Network/ShrinkNetworkCommand*.cs" />
<Compile Include="../../../Assets/ShrinkInspection/Runtime/**/*.cs" />
<Compile Include="../../../Assets/Modules/ShrinkDataSaver/Runtime/*.cs" Exclude="../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverRuntime.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverSettings.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkDataSaverBootstrap.cs;../../../Assets/Modules/ShrinkDataSaver/Runtime/ShrinkModuleRegistrar.cs" />
<Compile Include="../../../Assets/Modules/ShrinkDataSaver/DotNet~/Runtime/*.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
using NUnit.Framework;
using ShrinkDataSaver;
using ShrinkSDK.Inspection;
using ShrinkNetwork;
using Cysharp.Threading.Tasks;
[NonParallelizable]
public class SaveSnapshotTests
{
private string _directory = null!;
[SetUp] public void Setup()
{
_directory = Path.Combine(Path.GetTempPath(), "ShrinkSaveTests", Guid.NewGuid().ToString("N"));
ShrinkSave.Initialize(new LocalStorageProvider(_directory), "saves", ".json", 1);
}
[TearDown] public void Cleanup()
{
ShrinkSave.UnregisterModule("test-snapshot");
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
}
[TestCase(false)] [TestCase(true)]
public async Task CaptureRunsOnCallerAndWorkerEncodingPreservesFormat(bool encrypt)
{
var owner = Environment.CurrentManagedThreadId;
var captured = 0;
var value = new SaveDto { Value = "original" };
ShrinkSave.RegisterModule("test-snapshot", () => { captured = Environment.CurrentManagedThreadId; return value; }, _ => { });
var options = new SaveOptions { EncodeInBackground = true, CollectTimings = true, Encrypt = encrypt, EncryptionKey = "test-only" };
var saving = ShrinkSave.SaveSlotAsync(0, options).AsTask();
value.Value = "changed-after-capture";
await saving;
var file = Directory.GetFiles(Path.Combine(_directory, "saves"), "*.json").Single();
var packet = DataSerializer.Deserialize<SavePacket>(await File.ReadAllBytesAsync(file));
var text = encrypt ? System.Text.Encoding.UTF8.GetString(SaveEncryptor.Decrypt(Convert.FromBase64String(packet.EncryptedModules), "test-only")) : packet.Modules["test-snapshot"].ToString();
Assert.That(captured, Is.EqualTo(owner));
Assert.That(text, Does.Contain("original").And.Not.Contain("changed-after-capture"));
Assert.That(options.LastTimings.EncodedBytes, Is.GreaterThan(0));
Assert.That(options.LastTimings.CaptureMilliseconds, Is.GreaterThanOrEqualTo(0));
}
[Test]
public async Task CancellationLeavesExistingFileAndBackupsRecoverCorruption()
{
var storage = new LocalStorageProvider(_directory);
await storage.WriteAsync("slot.json", new byte[] { 1 });
await storage.WriteAsync("slot.json", new byte[] { 2 });
using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
Assert.CatchAsync<OperationCanceledException>(async () => await storage.WriteAsync("slot.json", new byte[] { 3 }, cancellation.Token));
Assert.That(await storage.ReadAsync("slot.json"), Is.EqualTo(new byte[] { 2 }));
Assert.That(await storage.ReadAsync("slot.json.bak1"), Is.EqualTo(new byte[] { 1 }));
Assert.That(File.Exists(Path.Combine(_directory, "slot.json.tmp")), Is.False);
}
[Test]
public async Task CorruptPrimarySaveFallsBackToPreviousValidPacket()
{
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "previous" });
await ShrinkSave.SaveSlotAsync(0, new SaveOptions { SlotName = "latest" });
var primary = Directory.GetFiles(Path.Combine(_directory, "saves"), "*.json").Single();
await File.WriteAllTextAsync(primary, "corrupt");
Assert.That((await ShrinkSave.GetMetaAsync(0)).SlotName, Is.EqualTo("previous"));
}
[Test]
public void RuntimeSnapshotExportsBindingsWithoutSessionSecrets()
{
var service = new ShrinkNetworkService(); service.RegisterMessage<CodecMessage>(42, "test/message");
service.RegisterHandler<CodecMessage>(Receive);
var snapshot = new ShrinkInspectionSnapshot("build-a");
snapshot.AddNetwork("server", service);
var json = snapshot.ToJson();
Assert.That(snapshot.Relations.Any(r => r.From == "opcode:42" && r.To == "type:CodecMessage"));
Assert.That(snapshot.Relations.Any(r => r.To == "M:SaveSnapshotTests.Receive(ShrinkNetwork.ShrinkNetworkContext,CodecMessage)"));
Assert.That(json, Does.Not.Contain("Payload").And.Not.Contain("SessionToken"));
}
private static UniTask Receive(ShrinkNetworkContext ctx, CodecMessage message) => UniTask.CompletedTask;
public sealed class SaveDto { public string Value { get; set; } = ""; }
}