AyCode.Core/AyCode.Services.Server.Tests/SignalRs/AcBinaryHubProtocolConcurre...

155 lines
7.3 KiB
C#

using System.Buffers;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR.Protocol;
namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Concurrency / thread-safety stress tests for <see cref="AcBinaryHubProtocol"/> — 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).
///
/// <para>Mirrors the SignalR DI-singleton scenario: one <see cref="AyCodeBinaryHubProtocol"/>
/// instance, many threads invoking <c>WriteMessage</c> + <c>TryParseMessage</c> in parallel with
/// distinct payloads. If the prior <c>_currentHeaderContext</c> 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.</para>
///
/// <para>The <see cref="TestInvocationBinder"/> declares the data arg as <c>typeof(object)</c>,
/// which forces <see cref="AyCodeBinaryHubProtocol.ReadSingleArgument"/> down the
/// header-context-driven type-resolution path — exactly the path the race used to corrupt.</para>
/// </summary>
[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<byte>(8192);
protocol.WriteMessage(msg, writer);
var seq = new ReadOnlySequence<byte>(writer.WrittenMemory);
var success = protocol.TryParseMessage(ref seq, binder, out var parsed);
Assert.IsTrue(success, $"thread={threadIdx} iter={i}: TryParseMessage returned false");
Assert.IsInstanceOfType<InvocationMessage>(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<SignalParams>(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<byte>(8192);
protocol.WriteMessage(msg, writer);
var seq = new ReadOnlySequence<byte>(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);
}
}