test(windev): buffer the test named pipes so the full-suite testhost exits
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m14s
ci / java (push) Successful in 2m19s
ci / portable (push) Successful in 8m48s

The windev full-suite wedge — every test reported, then the x64 testhost sitting
at ~0 CPU forever while `dotnet test` never returns — was one test blocked on a
pipe write, not a leaked thread or an undisposed fixture.

`dotnet-stack report` on the wedged host showed no thread running test code:
xUnit's RunTestsInAssembly was parked on WaitHandle.WaitOne() waiting for the
assembly-finished event, so the wait lived in a suspended async state machine.
`dotnet-dump analyze -c dumpasync` named the frame — WorkerClientTests
.StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout awaiting
WorkerFrameWriter.WriteAsync on a 63-byte frame, with <extra>5__15 = 6, i.e. the
seventh of the twelve events the test pushes past the client's staging bound.

That test faults the worker client on purpose, and a faulted client stops its
read loop by design. The test-side pipe came from the NamedPipeServerStream
overload without buffer arguments, which passes inBufferSize: 0 / outBufferSize: 0
to CreateNamedPipe; on Windows that reserves no buffer at all, so a write
completes only once the peer reads it. Measured on windev, that pipe absorbed
0 bytes against a non-reading peer where the same pipe declared with 64 KiB
buffers absorbed 65 520. On macOS and Linux .NET backs named pipes with Unix
domain sockets whose socket buffer swallows the writes regardless, which is why
the identical test never hung there and the bug read as environmental.

Test-owned server pipes now go through TestSupport/TestNamedPipe.CreateServer in
both test projects, declaring explicit 64 KiB buffers so those tests exercise the
gateway's own staging/queue backpressure rather than the OS pipe's flow control.

Separately, every fake-worker write in WorkerClientTests now goes through
PipePair.WriteAsync, bounded by the class's five-second TestTimeout. That is
where the severity came from: a test method that never returns keeps xUnit from
raising ITestAssemblyFinished, so one unbounded await cost the whole suite its
result. A blocked write is now a named test failure instead of a silent wedge.

The fix also retires a wrong belief the wedge had created. windev reported 855
where macOS reported 879, and that gap was recorded in GatewayTesting.md as
Unix-gated test cases; it was really the results lost when the wedged host was
torn down. The same clone now reports 879 passed, matching macOS exactly.

Product code is unaffected. SessionWorkerClientFactory.CreatePipe keeps the
unbuffered declaration deliberately: both ends run continuous read loops and every
gateway write is bounded by the worker client's _stopCts, so a stalled peer
cancels the write rather than blocking on it.

Verified on windev at this SHA: gateway suite x64 three times (879 passed,
exit 0, no surviving testhost each time) and Worker.Tests x86 twice (400 passed,
11 skipped, exit 0, clean), plus the macOS gateway suite once (879 passed).

Docs: GatewayTesting.md replaces the --blame-hang workaround section with the
root cause and corrects the baseline to 879, CLAUDE.md's Source Update Workflow
no longer tells readers the windev suite wedges, and ToolchainLinks.md records
dotnet-stack and dotnet-dump as installed on windev.
This commit is contained in:
Joseph Doherty
2026-08-10 09:55:58 -04:00
parent f78781d9ef
commit bfcf82975c
9 changed files with 217 additions and 70 deletions
@@ -0,0 +1,55 @@
using System.IO.Pipes;
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
/// <summary>
/// Creates the gateway-side <see cref="NamedPipeServerStream"/> that in-process worker fakes connect
/// to, with an explicit OS buffer so a test that deliberately stops draining the pipe cannot block
/// its own writer forever.
/// </summary>
/// <remarks>
/// The <c>NamedPipeServerStream(string, PipeDirection, int, PipeTransmissionMode, PipeOptions)</c>
/// overload passes <c>inBufferSize: 0</c> and <c>outBufferSize: 0</c> to <c>CreateNamedPipe</c>. On
/// Windows that reserves <em>no</em> buffer at all: a write completes only once the peer reads it —
/// measured on a Windows 10 host, the very first 63-byte write to a non-reading peer never completed,
/// against 65 520 bytes absorbed by the same pipe declared with 64 KiB buffers. On macOS and Linux
/// .NET backs named pipes with Unix domain sockets, whose socket buffer absorbs several kilobytes
/// regardless, so the zero-buffer declaration is invisible there.
/// <para>
/// That asymmetry wedged the whole Windows test host: <c>WorkerClientTests</c> pushes events past the
/// worker client's staging bound to prove the client faults, and after the fault the client's read
/// loop stops by design. On Windows the next test-side write then blocked forever with no timeout, so
/// the test never returned, xUnit never raised <c>ITestAssemblyFinished</c>, and <c>testhost</c> never
/// exited even though every other test had already passed.
/// </para>
/// <para>
/// Buffering the test pipe scopes those tests to the behavior they are actually asserting — the
/// gateway's own staging/queue backpressure — instead of the OS pipe's flow control. The gateway's
/// production pipe in <c>SessionWorkerClientFactory</c> keeps the unbuffered declaration on purpose:
/// there both ends run continuous read loops and every write is bounded by the worker client's stop
/// token, so a stalled peer cancels rather than blocks.
/// </para>
/// </remarks>
internal static class TestNamedPipe
{
/// <summary>
/// The buffer reserved for each direction. Comfortably larger than any frame volume a test
/// pushes at a stopped reader, and the size Windows itself uses for a typical named pipe.
/// </summary>
internal const int BufferBytes = 64 * 1024;
/// <summary>Creates the gateway-side server pipe for <paramref name="pipeName"/>.</summary>
/// <param name="pipeName">The pipe name the fake worker connects to.</param>
/// <returns>An asynchronous, byte-mode server pipe with explicit buffers.</returns>
internal static NamedPipeServerStream CreateServer(string pipeName)
{
return new NamedPipeServerStream(
pipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: BufferBytes,
outBufferSize: BufferBytes);
}
}