using System.Buffers; using AyCode.Services.SignalRs; using Microsoft.AspNetCore.SignalR.Protocol; namespace AyCode.Services.Server.Tests.SignalRs; /// /// Concurrency / thread-safety stress tests for — verify a /// single shared protocol instance can serve many threads concurrently without per-message state /// corruption. Regression guard for the per-message header-context race fix (header context is /// now stack-only on the non-chunked path and per-binder on the chunked path; never on a shared /// instance field). /// /// Mirrors the SignalR DI-singleton scenario: one /// instance, many threads invoking WriteMessage + TryParseMessage in parallel with /// distinct payloads. If the prior _currentHeaderContext race were present, threads would /// see each other's header context (typically a wrong type AQN) → mismatched parse output, type /// cast exception, or null where a value is expected. /// /// The declares the data arg as typeof(object), /// which forces down the /// header-context-driven type-resolution path — exactly the path the race used to corrupt. /// [TestClass] public class AcBinaryHubProtocolConcurrencyTests { [TestMethod] public async Task ConcurrentRoundTrip_SharedProtocolInstance_NoStateCorruption() { // Single shared protocol instance — DI-singleton style, the production-realistic NuGet shape. var protocol = new AyCodeBinaryHubProtocol(); var binder = new TestInvocationBinder(); const int threadCount = 16; const int iterationsPerThread = 200; var tasks = new Task[threadCount]; for (var t = 0; t < threadCount; t++) { var threadIdx = t; tasks[t] = Task.Run(() => { for (var i = 0; i < iterationsPerThread; i++) { var dataId = threadIdx * 10000 + i; // Distinct payload per (thread, iteration) — race-induced corruption produces // detectable mismatch in the assertions below. var msg = new InvocationMessage("OnReceiveMessage", new object?[] { dataId, // arg[0]: int (messageTag) (int?)i, // arg[1]: int? (requestId) new SignalParams(), // arg[2]: SignalParams $"data-t{threadIdx}-i{i}" // arg[3]: object → string (header-context-typed) }); var writer = new ArrayBufferWriter(8192); protocol.WriteMessage(msg, writer); var seq = new ReadOnlySequence(writer.WrittenMemory); var success = protocol.TryParseMessage(ref seq, binder, out var parsed); Assert.IsTrue(success, $"thread={threadIdx} iter={i}: TryParseMessage returned false"); Assert.IsInstanceOfType(parsed, $"thread={threadIdx} iter={i}: parsed message wrong type"); var inv = (InvocationMessage)parsed!; Assert.AreEqual(4, inv.Arguments.Length, $"thread={threadIdx} iter={i}: argument count mismatch"); Assert.AreEqual(dataId, (int)inv.Arguments[0]!, $"thread={threadIdx} iter={i}: arg[0] (messageTag) mismatch — possible race"); Assert.AreEqual(i, (int?)inv.Arguments[1], $"thread={threadIdx} iter={i}: arg[1] (requestId) mismatch"); Assert.IsInstanceOfType(inv.Arguments[2], $"thread={threadIdx} iter={i}: arg[2] not SignalParams — header-context race?"); Assert.AreEqual($"data-t{threadIdx}-i{i}", inv.Arguments[3] as string, $"thread={threadIdx} iter={i}: arg[3] (data) mismatch — header-context race?"); } }); } await Task.WhenAll(tasks); } [TestMethod] public async Task ConcurrentRoundTrip_VariedPayloadTypes_HeaderContextResolvedPerMessage() { // Stresses the header-context Type-resolution path more aggressively: each iteration // alternates between distinct payload types (string / int / int[]). A header-context // race would resolve the wrong type AQN → InvalidCastException or silent type-mismatch. var protocol = new AyCodeBinaryHubProtocol(); var binder = new TestInvocationBinder(); const int threadCount = 8; const int iterationsPerThread = 150; var tasks = new Task[threadCount]; for (var t = 0; t < threadCount; t++) { var threadIdx = t; tasks[t] = Task.Run(() => { for (var i = 0; i < iterationsPerThread; i++) { var dataId = threadIdx * 10000 + i; object data = (i % 3) switch { 0 => $"str-t{threadIdx}-i{i}", 1 => dataId, _ => new[] { dataId, threadIdx, i } }; var msg = new InvocationMessage("OnReceiveMessage", new object?[] { dataId, (int?)i, new SignalParams(), data }); var writer = new ArrayBufferWriter(8192); protocol.WriteMessage(msg, writer); var seq = new ReadOnlySequence(writer.WrittenMemory); Assert.IsTrue(protocol.TryParseMessage(ref seq, binder, out var parsed), $"thread={threadIdx} iter={i}: TryParseMessage returned false"); var inv = (InvocationMessage)parsed!; Assert.AreEqual(dataId, (int)inv.Arguments[0]!, $"thread={threadIdx} iter={i}: messageTag mismatch"); // Validate the header-context-driven type was resolved correctly per-message. switch (i % 3) { case 0: Assert.AreEqual($"str-t{threadIdx}-i{i}", inv.Arguments[3] as string, $"thread={threadIdx} iter={i}: string payload mismatch — header-context race?"); break; case 1: Assert.AreEqual(dataId, (int)inv.Arguments[3]!, $"thread={threadIdx} iter={i}: int payload mismatch — header-context race?"); break; default: var arr = inv.Arguments[3] as int[]; Assert.IsNotNull(arr, $"thread={threadIdx} iter={i}: int[] payload null — header-context race?"); Assert.AreEqual(3, arr.Length); Assert.AreEqual(dataId, arr[0]); break; } } }); } await Task.WhenAll(tasks); } }