test(flaky): deflake SessionManager fail-fast timing assertions
ci / java (push) Successful in 1m56s
ci / windows-x86 (push) Successful in 1m4s
ci / nightly-windev (push) Has been skipped
ci / portable (push) Successful in 7m3s

CI (portable, run #38) intermittently failed SessionManagerTests
.InvokeAsync_WhenTimeoutZero_FailsFastUnchanged with 'expected immediate
fail-fast but took 155ms'. The fail-fast paths throw synchronously under the
lock (GatewaySession.GetReadyWorkerClientAsync) and never enter the poll loop,
so the absolute <100ms wall-clock bound measured only host load, not behavior,
and flaked under CI contention.

- WhenWorkerFaulted_FailsFastWithBothStates: anchor the bound to a large (5000ms)
  ready-wait timeout and assert fail-fast returns in < timeout/3 — a regression
  that burned the timeout is still caught, but scheduling jitter can't trip it.
- WhenTimeoutZero_FailsFastUnchanged: drop the wall-clock assertion entirely
  (a zero timeout has no wait window to burn); the error code, both-states
  message, and InvokeCount == 0 already pin the immediate fail-fast.

Verified: the 3 SessionManager timing tests pass (net10.0).
This commit is contained in:
Joseph Doherty
2026-07-13 11:20:49 -04:00
parent 5c8075996f
commit df7e20db1d
@@ -420,10 +420,16 @@ public sealed class SessionManagerTests
[Fact] [Fact]
public async Task InvokeAsync_WhenWorkerFaulted_FailsFastWithBothStates() public async Task InvokeAsync_WhenWorkerFaulted_FailsFastWithBothStates()
{ {
// Use a deliberately large ready-wait timeout so fail-fast is unambiguous: a terminal
// worker must surface immediately instead of burning it. The timing assertion below is
// anchored to a small fraction of this timeout (not an absolute ~100ms wall-clock bound,
// which flaked under CI load) so a regression that waited out the timeout is caught
// while ordinary scheduling jitter never trips it.
const int readyWaitTimeoutMs = 5000;
FakeWorkerClient workerClient = new() { State = WorkerClientState.Faulted }; FakeWorkerClient workerClient = new() { State = WorkerClientState.Faulted };
SessionManager manager = CreateManager( SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient), new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(workerReadyWaitTimeoutMs: 500)); options: CreateOptions(workerReadyWaitTimeoutMs: readyWaitTimeoutMs));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None); GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State); Assert.Equal(SessionState.Ready, session.State);
@@ -435,7 +441,9 @@ public sealed class SessionManagerTests
CancellationToken.None)); CancellationToken.None));
stopwatch.Stop(); stopwatch.Stop();
Assert.True(stopwatch.ElapsedMilliseconds < 100, $"Expected immediate fail-fast but took {stopwatch.ElapsedMilliseconds}ms."); Assert.True(
stopwatch.ElapsedMilliseconds < readyWaitTimeoutMs / 3,
$"Expected fail-fast well under the {readyWaitTimeoutMs}ms ready-wait timeout but took {stopwatch.ElapsedMilliseconds}ms.");
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode); Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
Assert.Contains("Session state is Ready", exception.Message); Assert.Contains("Session state is Ready", exception.Message);
Assert.Contains("worker state is Faulted", exception.Message); Assert.Contains("worker state is Faulted", exception.Message);
@@ -487,15 +495,17 @@ public sealed class SessionManagerTests
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None); GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State); Assert.Equal(SessionState.Ready, session.State);
Stopwatch stopwatch = Stopwatch.StartNew(); // A zero timeout has no wait window to burn, so there is no wall-clock timing to assert
// (the previous absolute ~100ms bound only measured host load and flaked under CI). The
// error code, the byte-for-byte both-states message, and InvokeCount == 0 fully pin the
// immediate fail-fast — a regression that started polling would still surface the same
// outcome, not a timing difference worth a flaky assertion.
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>( SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.InvokeAsync( async () => await manager.InvokeAsync(
session.SessionId, session.SessionId,
CreateCommand(MxCommandKind.Ping), CreateCommand(MxCommandKind.Ping),
CancellationToken.None)); CancellationToken.None));
stopwatch.Stop();
Assert.True(stopwatch.ElapsedMilliseconds < 100, $"Expected immediate fail-fast but took {stopwatch.ElapsedMilliseconds}ms.");
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode); Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
Assert.Contains("Session state is Ready", exception.Message); Assert.Contains("Session state is Ready", exception.Message);
Assert.Contains("worker state is Handshaking", exception.Message); Assert.Contains("worker state is Handshaking", exception.Message);