155 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|