From bfaf7a84563115713bea47f0eaffd14f08b045e3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 03:56:24 +0200 Subject: [PATCH] Gate retained operation session growth --- samples/eve-browser-network/Program.cs | 143 ++++++++++++++++-- samples/eve-browser-network/README.md | 5 + scripts/verify-eve-browser-network.mjs | 5 + .../docs/browser-verse-transport.md | 9 +- .../03-publish-an-eve-surface.md | 4 +- .../docs/getting-started/README.md | 4 +- 6 files changed, 153 insertions(+), 17 deletions(-) diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs index 25809744..5b8e132f 100644 --- a/samples/eve-browser-network/Program.cs +++ b/samples/eve-browser-network/Program.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Diagnostics; using System.Text.Json; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -97,7 +98,24 @@ static async Task RunProviderAsync(Args arguments) var operationGate = new SemaphoreSlim(1, 1); schemaServer.OnCultNet(async (request, peer) => { - if (request.ServiceId != "sample.counter" || request.Operation != "sample.counter.increment") + if (request.ServiceId != "sample.counter") + { + peer.SendCultNet(new CultNetErrorMessage { Error = "Unsupported sample operation." }); + return; + } + if (request.Operation == "sample.counter.ping") + { + var ping = MessagePackSerializer.Deserialize( + Convert.FromBase64String(request.Payload), + CultNetSchemaMessageSerialization.Options); + SendOperationResponse( + peer, + request, + "sample.ping_receipt.v1", + new PingReceipt { Sequence = ping.Sequence }); + return; + } + if (request.Operation != "sample.counter.increment") { peer.SendCultNet(new CultNetErrorMessage { Error = "Unsupported sample operation." }); return; @@ -110,7 +128,8 @@ static async Task RunProviderAsync(Args arguments) if (!current.Receipts.TryGetValue(request.MessageId, out var receipt)) { var input = MessagePackSerializer.Deserialize( - Convert.FromBase64String(request.Payload)); + Convert.FromBase64String(request.Payload), + CultNetSchemaMessageSerialization.Options); receipt = IncrementReceipt.Accepted(request.MessageId, current.Count + input.Amount); var receipts = new Dictionary(current.Receipts, StringComparer.Ordinal) { @@ -124,16 +143,7 @@ static async Task RunProviderAsync(Args arguments) }); await cache.FlushAsync(); } - peer.SendCultNet(new CultNetOperationResponseMessage - { - MessageId = request.MessageId, - ServiceId = request.ServiceId, - Operation = request.Operation, - Status = "accepted", - PayloadSchema = "gamecult.eve.command_receipt.v1", - Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(receipt)), - SourceRuntimeId = "sample.counter-provider" - }); + SendOperationResponse(peer, request, "gamecult.eve.command_receipt.v1", receipt); } finally { @@ -229,10 +239,101 @@ static async Task RunHeadlessAsync(Args arguments) count = result.Value.Count, status = result.Status })); + Console.WriteLine("HEADLESS_NETWORK_BENCHMARK " + JsonSerializer.Serialize( + await MeasureOperationSessionAsync(mesh, arguments))); }); await Task.WhenAll(completion.Task, commandTask).WaitAsync(TimeSpan.FromSeconds(45)); } +static void SendOperationResponse( + ICultNetSchemaServerPeer peer, + CultNetOperationRequestMessage request, + string payloadSchema, + T payload) +{ + peer.SendCultNet(new CultNetOperationResponseMessage + { + MessageId = request.MessageId, + ServiceId = request.ServiceId, + Operation = request.Operation, + Status = "accepted", + PayloadSchema = payloadSchema, + Payload = Convert.ToBase64String(MessagePackSerializer.Serialize( + payload, + CultNetSchemaMessageSerialization.Options)), + SourceRuntimeId = "sample.counter-provider" + }); +} + +static async Task MeasureOperationSessionAsync(CultMeshClient mesh, Args arguments) +{ + const int warmupOperations = 100; + if (arguments.BenchmarkOperations <= 0) + throw new ArgumentOutOfRangeException(nameof(arguments.BenchmarkOperations)); + for (var index = 0; index < warmupOperations; index++) + await PingAsync(mesh, arguments, "warmup:" + index, index); + + ForceCollection(); + using var process = Process.GetCurrentProcess(); + process.Refresh(); + var managedHeapBefore = GC.GetTotalMemory(forceFullCollection: false); + var privateBytesBefore = process.PrivateMemorySize64; + var latencies = new double[arguments.BenchmarkOperations]; + var clock = Stopwatch.StartNew(); + for (var index = 0; index < arguments.BenchmarkOperations; index++) + { + var started = Stopwatch.GetTimestamp(); + await PingAsync(mesh, arguments, "benchmark:" + index, index); + latencies[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + } + clock.Stop(); + ForceCollection(); + process.Refresh(); + var managedHeapAfter = GC.GetTotalMemory(forceFullCollection: false); + var privateBytesAfter = process.PrivateMemorySize64; + Array.Sort(latencies); + var p99 = latencies[Math.Clamp((int)Math.Ceiling(latencies.Length * 0.99) - 1, 0, latencies.Length - 1)]; + var managedHeapGrowth = managedHeapAfter - managedHeapBefore; + if (managedHeapGrowth > 8 * 1024 * 1024) + throw new InvalidOperationException($"Retained operation session grew the managed heap by {managedHeapGrowth} bytes."); + if (p99 > 250) + throw new InvalidOperationException($"Retained operation session p99 was {p99:F2} ms."); + return new + { + operations = arguments.BenchmarkOperations, + p99Milliseconds = p99, + operationsPerSecond = arguments.BenchmarkOperations / clock.Elapsed.TotalSeconds, + managedHeapBefore, + managedHeapAfter, + managedHeapGrowth, + privateBytesBefore, + privateBytesAfter, + privateBytesGrowth = privateBytesAfter - privateBytesBefore + }; +} + +static async Task PingAsync(CultMeshClient mesh, Args arguments, string idempotencyKey, int sequence) +{ + var result = await mesh.InvokeAsync( + arguments.VerseId, + "sample.counter", + "sample.counter.ping", + "sample.ping_request.v1", + "sample.ping_receipt.v1", + new PingRequest { Sequence = sequence }, + sourceRuntimeId: "sample.csharp", + idempotencyKey: idempotencyKey); + if (result.Status != "accepted" || result.Value.Sequence != sequence) + throw new InvalidOperationException("The typed ping receipt did not match its request."); +} + +static void ForceCollection() +{ + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); +} + static EveSurfaceDocument CreateCounterSurface() { var networkRoute = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh"); @@ -300,6 +401,7 @@ sealed class Args public string TransportVersion { get; private init; } = "cultmesh.v0"; public string RulesHash { get; private init; } = "sample-counter-v1"; public int ExpectedCount { get; private init; } = 2; + public int BenchmarkOperations { get; private init; } = 10_000; public static Args Parse(string[] values) { @@ -322,7 +424,10 @@ sealed class Args RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"), ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount) ? int.Parse(expectedCount) - : 2 + : 2, + BenchmarkOperations = named.TryGetValue("--benchmark-operations", out var benchmarkOperations) + ? int.Parse(benchmarkOperations) + : 10_000 }; } } @@ -370,3 +475,15 @@ public sealed class IncrementReceipt Count = count }; } + +[MessagePackObject] +public sealed class PingRequest +{ + [Key("sequence")] public int Sequence { get; set; } +} + +[MessagePackObject] +public sealed class PingReceipt +{ + [Key("sequence")] public int Sequence { get; set; } +} diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md index 69720f7e..c039cd42 100644 --- a/samples/eve-browser-network/README.md +++ b/samples/eve-browser-network/README.md @@ -33,6 +33,11 @@ port, updates the local Odin fixture, and requires both retained clients to rediscover and resubscribe. The C# client invokes the second command through `CultMeshClient.InvokeAsync`; Chromium observes the resulting count and both clients observe both canonical receipt ids. +The retained C# client then sends 10,000 typed no-op operations over the same +real WebSocket session. The gate records p99 latency, throughput, managed heap, +and private memory, and rejects more than 8 MiB of post-GC managed growth or a +250 ms p99. The no-op provider handler stores no receipt history, so persistence +growth cannot impersonate a client leak. The fixture speaks the production Verse-catalog contract; a deployment smoke against the full Odin daemon remains a separate infrastructure gate. diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs index c82b7c50..3f633dc9 100644 --- a/scripts/verify-eve-browser-network.mjs +++ b/scripts/verify-eve-browser-network.mjs @@ -109,6 +109,10 @@ try { assert.equal(secondHeadlessUpdate.count, 2); assert.ok(secondHeadlessUpdate.receiptIds.includes(firstReceipt)); assert.ok(secondHeadlessUpdate.receiptIds.includes(secondReceipt)); + const networkBenchmark = JSON.parse(await headless.waitFor("HEADLESS_NETWORK_BENCHMARK ", 60_000)); + assert.equal(networkBenchmark.operations, 10_000); + assert.ok(networkBenchmark.p99Milliseconds < 250); + assert.ok(networkBenchmark.managedHeapGrowth <= 8 * 1024 * 1024); const connectionStates = await page.evaluate(() => window.__sampleConnectionStates); assert.equal(connectionStates[0], "connected"); assert.equal(connectionStates.at(-1), "connected"); @@ -126,6 +130,7 @@ try { count: 2, routeRotationCount: 1, retainedHeadlessLease: true, + networkBenchmark, })); } finally { if (browser) await browser.close().catch(() => undefined); diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index 58a00cae..ecec20ef 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -183,6 +183,9 @@ Current executable coverage from `scripts/verify-eve-browser-network.mjs`: second command through `CultMeshClient.InvokeAsync`, and receives the provider-authored receipt without application transport or reconnect code; - Chromium observes the C# command's canonical state and receipt chronology; +- 10,000 typed no-op operations traverse the retained C# WebSocket session; + the gate records throughput, p99, post-GC managed growth, and private-memory + movement without growing provider state; - generated browser bundle contains no `node:` builtin import. The full public Verse gate still requires the deployed Odin daemon rather than @@ -203,9 +206,11 @@ From an empty temporary consumer using packed/released artifacts only: 8. invoke from C# after route replacement and require Chromium to observe the same provider-authored state and receipt; 9. restart the provider and prove state plus receipt persistence; -10. reject malformed schemas, direct document writes, duplicate command +10. run a non-persistent operation workload long enough to falsify retained + callback or managed-heap growth; +11. reject malformed schemas, direct document writes, duplicate command effects, stale subscription generations, and unsupported authority; -11. inspect the browser bundle and fail if it contains Node builtin imports. +12. inspect the browser bundle and fail if it contains Node builtin imports. The gate runs on Windows and Linux. A screenshot is useful presentation evidence, but the receipt/state chronology is the authority proof. diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index 8e7a8869..a9126cd7 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -63,7 +63,9 @@ CultNet WebSocket lanes. The open browser resolves provider identity through the canonical Verse catalog, survives a provider restart on a different route, and resubscribes. The retained C# client then invokes a second typed operation through `CultMeshClient.InvokeAsync`; Chromium and C# observe the same -provider-authored state and receipt chronology. +provider-authored state and receipt chronology. The same retained C# session +then completes 10,000 typed no-op operations under explicit p99 and post-GC +managed-memory gates, so the tutorial pressure-tests the API it teaches. Current verification for the Unity consumer remains: diff --git a/src/GameCult.Mesh/docs/getting-started/README.md b/src/GameCult.Mesh/docs/getting-started/README.md index e0bd2074..5251c7ca 100644 --- a/src/GameCult.Mesh/docs/getting-started/README.md +++ b/src/GameCult.Mesh/docs/getting-started/README.md @@ -18,7 +18,9 @@ headless observer converge, and reopens the durable `.cc` store. Then it boots a durable C# provider, local Odin fixture, real Chromium lowerer, and independent C# client. Chromium invokes before provider replacement; C# invokes afterward. Both retained clients prove discovery by identity, canonical receipts, provider -restart on another route, resubscription, and durable convergence. +restart on another route, resubscription, and durable convergence. The retained +C# session also completes 10,000 non-persistent typed operations under p99 and +post-GC managed-memory gates. Set `CHROME_PATH` only when no installed Chrome, Chromium, Edge, or Playwright Chromium can be found automatically.