170 lines
8.2 KiB
C#
170 lines
8.2 KiB
C#
using System.IO.Pipelines;
|
|
using AyCode.Services.SignalRs;
|
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
|
|
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
|
|
|
/// <summary>
|
|
/// Thread-pool starvation regression tests for the AsyncSegment paths
|
|
/// (<c>ACCORE-SBP-I-T3W9</c>: blocking waits on .NET thread-pool threads deadlock the pool under
|
|
/// concurrent load + real network latency — production-only, "stuck loading → 60 s timeout").
|
|
///
|
|
/// <para><b>Deser (receive) side — fixed.</b> The streaming deser blocks in
|
|
/// <c>ManualResetEventSlim.Wait()</c> between network-delivered chunks. It now runs off the .NET
|
|
/// thread pool on a never-queue + reuse dedicated executor (<see cref="AcBinaryDeserExecutor"/>) so
|
|
/// the blocking wait never consumes a pool thread (which the producer's receive-continuations need
|
|
/// → circular dependency). These tests assert that executor contract directly and should all pass.</para>
|
|
///
|
|
/// <para><b>Ser (send) side — TBD.</b> The per-chunk <c>SyncFlush</c> in <c>WriteMessageChunked</c>
|
|
/// blocks a dispatch (pool) thread under client backpressure. The send-side test below asserts the
|
|
/// DESIRED non-blocking behaviour and is <c>[Ignore]</c>d until that fix lands.</para>
|
|
/// </summary>
|
|
[TestClass]
|
|
public class AcBinaryProtocolThreadPoolTests
|
|
{
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Deser (receive) side — AcBinaryDeserExecutor contract. Expected: all pass.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
[TestMethod]
|
|
public void DeserExecutor_RunsOffThreadPool()
|
|
{
|
|
var onPool = (bool)AcBinaryDeserExecutor.Run(() => (object?)Thread.CurrentThread.IsThreadPoolThread).GetAwaiter().GetResult()!;
|
|
|
|
Assert.IsFalse(onPool,
|
|
"Streaming deser must run OFF the .NET thread pool (dedicated thread) — the blocking " +
|
|
"MRES.Wait() would otherwise pin a pool thread the producer's receive-continuation needs " +
|
|
"→ circular-dependency starvation (ACCORE-SBP-I-T3W9).");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void DeserExecutor_NeverQueues_AllConcurrentWorkStartsImmediately()
|
|
{
|
|
const int n = 32;
|
|
using var allStarted = new CountdownEvent(n);
|
|
using var release = new ManualResetEventSlim(false);
|
|
|
|
var tasks = new Task<object?>[n];
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
tasks[i] = AcBinaryDeserExecutor.Run(() =>
|
|
{
|
|
allStarted.Signal();
|
|
release.Wait();
|
|
|
|
return (object?)null;
|
|
});
|
|
}
|
|
|
|
// Every item must START even though none has finished — proves the executor NEVER queues:
|
|
// each submit immediately gets a thread (reuse idle or grow). A bounded-queue executor
|
|
// would stall here (excess items wait behind busy workers) and the countdown never reaches 0.
|
|
// 5 s is generous for 32 thread spawns (~ms) yet fails fast on a queue regression.
|
|
Assert.IsTrue(allStarted.Wait(TimeSpan.FromSeconds(5)),
|
|
$"Only {n - allStarted.CurrentCount}/{n} work items started — executor queued instead of " +
|
|
"growing on demand (would reintroduce the [202] GetResult-on-queued-deser coupling).");
|
|
|
|
release.Set();
|
|
Task.WaitAll(tasks);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void DeserExecutor_ReusesThreads_AcrossSequentialWork()
|
|
{
|
|
var ids = new HashSet<int>();
|
|
for (var i = 0; i < 50; i++)
|
|
{
|
|
var id = (int)AcBinaryDeserExecutor.Run(() => (object?)Environment.CurrentManagedThreadId).GetAwaiter().GetResult()!;
|
|
ids.Add(id);
|
|
}
|
|
|
|
// Sequential work: each completes before the next is submitted → the same idle worker is
|
|
// reused (no per-message thread churn). The worker pushes itself to idle right after
|
|
// SetResult (1 instruction) while the test thread runs ~10+ (incl. a TCS alloc) before the
|
|
// next TryPop, so the worker almost always wins the race → 1-2 distinct threads in practice.
|
|
// <= 4 leaves a small margin for the rare preempt-in-window case; more than that is suspect.
|
|
Assert.IsTrue(ids.Count <= 4, $"Expected thread reuse across sequential work, got {ids.Count} distinct threads / 50 — churn instead of reuse.");
|
|
}
|
|
|
|
[TestMethod]
|
|
[DoNotParallelize] // stresses the shared process-wide thread pool
|
|
public void DeserExecutor_CompletesUnderThreadPoolPressure()
|
|
{
|
|
// Occupy many .NET thread-pool threads with blocking work, then run a deser-style item.
|
|
// Because the executor is off-pool it completes regardless of pool pressure — whereas the
|
|
// old `Task.Run` deser would queue behind the busy pool and the [202] GetResult would block
|
|
// (ACCORE-SBP-I-T3W9). This is the end-to-end resilience guard.
|
|
using var release = new ManualResetEventSlim(false);
|
|
var hogCount = Environment.ProcessorCount * 4;
|
|
var hogs = new Task[hogCount];
|
|
|
|
for (var i = 0; i < hogCount; i++) hogs[i] = Task.Run(() => release.Wait());
|
|
|
|
try
|
|
{
|
|
var done = AcBinaryDeserExecutor.Run(() => (object?)42);
|
|
Assert.IsTrue(done.Wait(TimeSpan.FromSeconds(5)), "Off-pool deser did not complete under thread-pool pressure — possible pool coupling.");
|
|
Assert.AreEqual(42, (int)done.Result!);
|
|
}
|
|
finally
|
|
{
|
|
release.Set();
|
|
Task.WaitAll(hogs);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Ser (send) side — known-open. Asserts DESIRED behaviour; [Ignore]d until the send-side fix.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
[TestMethod]
|
|
[Ignore("Send-side fix TBD — ACCORE-SBP-I-T3W9. Asserts the DESIRED non-blocking behaviour; " +
|
|
"today WriteMessageChunked blocks the calling (dispatch) thread under client backpressure " +
|
|
"via the per-chunk SyncFlush. Un-ignore when the send-side fix lands.")]
|
|
public void WriteMessageChunked_UnderBackpressure_DoesNotBlockCallingThread()
|
|
{
|
|
// A Pipe with a small pause threshold and NO reader → FlushAsync backpressures immediately
|
|
// (simulates a stuck / slow mobile client). The chunked send's per-chunk SyncFlush then
|
|
// blocks the calling thread. Desired post-fix behaviour: the send does not pin the caller.
|
|
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 4096, resumeWriterThreshold: 2048));
|
|
|
|
var opts = new AcBinaryHubProtocolOptions
|
|
{
|
|
ProtocolMode = BinaryProtocolMode.AsyncSegment,
|
|
FlushTimeout = TimeSpan.FromSeconds(2), // bound so a stuck flush can't hang the test forever
|
|
};
|
|
var protocol = new AyCodeBinaryHubProtocol(opts);
|
|
|
|
// Large non-byte[] data arg → the chunked path engages and produces > pauseThreshold bytes.
|
|
// The 4-arg shape mirrors the OnReceiveMessage(tag, requestId, SignalParams, data) convention
|
|
// the other SignalR tests use — SignalParams here is convention/realism, not functionally
|
|
// required (the large `bigData` arg alone triggers HasStreamableArgs → chunked send).
|
|
var bigData = new string('x', 200_000);
|
|
var msg = new InvocationMessage("OnReceiveMessage", new object?[]
|
|
{
|
|
1, (int?)1, new SignalParams(), bigData
|
|
});
|
|
|
|
var writeThread = new Thread(() =>
|
|
{
|
|
try
|
|
{
|
|
protocol.WriteMessage(msg, pipe.Writer);
|
|
}
|
|
catch
|
|
{
|
|
/* TimeoutException on the bounded flush is acceptable for this probe */
|
|
}
|
|
})
|
|
{ IsBackground = true };
|
|
writeThread.Start();
|
|
|
|
// 2 s margin so a non-blocking send (returns in ms once it no longer waits on the flush) is
|
|
// not falsely flagged on a slow CI; a blocking send stays pinned well past this.
|
|
var finishedPromptly = writeThread.Join(TimeSpan.FromSeconds(2));
|
|
Assert.IsTrue(finishedPromptly,
|
|
"WriteMessageChunked blocked the calling thread under client backpressure (per-chunk " +
|
|
"SyncFlush) — the send-side thread-pool starvation source (ACCORE-SBP-I-T3W9).");
|
|
}
|
|
}
|