using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
///
/// Concurrency and disposal regression tests for ,
/// covering the split lock discipline between _syncRoot (state transitions)
/// and _closeLock (close serialization) and the gated DisposeAsync.
///
public sealed class GatewaySessionTests
{
///
/// A TransitionTo(Ready) issued after
/// has set
/// must not flip the session back to . The
/// blocking worker shutdown keeps CloseAsync parked between the
/// Closing write and the Closed write, which is precisely the
/// window the audit identified.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. A concurrent transition (e.g. a late
// SessionWorkerClientFactory lifecycle callback) must not revive the session.
Assert.Equal(SessionState.Closing, session.State);
session.TransitionTo(SessionState.Ready);
Assert.Equal(SessionState.Closing, session.State);
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
///
/// Once finishes,
/// must not be able to move the
/// session out of either — the close path's
/// terminal write goes through the same _syncRoot the rest of the state
/// machine uses, so the existing "Closed is terminal" invariant holds.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
Assert.Equal(SessionState.Closed, session.State);
session.MarkFaulted("late-fault");
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
///
/// A issued
/// while is parked between its
/// Closing and Closed writes must not break the close path's
/// terminal contract: the in-flight close runs to Closed, the fault
/// reason is preserved on , and the
/// session does not get stuck in . The
/// state machine documents "Closing only allows a transition to Closed or
/// Faulted" — this test pins the resolved end state so a future tightening
/// of MarkFaulted cannot silently regress it.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. Fault the session from another thread while parked.
Assert.Equal(SessionState.Closing, session.State);
session.MarkFaulted("concurrent-fault");
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
// Close still wins — Closed is terminal — but the fault reason is preserved
// so observers see the original cause once the session settles.
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
Assert.Equal("concurrent-fault", session.FinalFault);
await session.DisposeAsync();
}
///
/// must wait
/// for an in-flight before disposing
/// its semaphore. Without the fix, the close's _closeLock.Release()
/// would race the dispose and raise .
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Start disposing while close is still parked inside worker.ShutdownAsync.
ValueTask disposeTask = session.DisposeAsync();
// Now release the worker shutdown so close can complete.
workerClient.ReleaseShutdown();
// Both must complete cleanly — the close's Release() must run before the
// dispose actually tears the semaphore down.
SessionCloseResult result = await closeTask;
await disposeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(1, workerClient.ShutdownCount);
// Worker dispose ran exactly once even with the close/dispose interleave.
Assert.Equal(1, workerClient.DisposeCount);
}
///
/// Double-dispose is tolerated: the second call must swallow
/// from the already-disposed semaphore
/// rather than propagating it.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
await session.DisposeAsync();
// No second exception — the dispose's defensive ObjectDisposedException catch
// covers the doubled call path that SessionManager.ShutdownAsync could trigger
// if it re-removed a session.
await session.DisposeAsync();
}
///
/// Concurrent Dispose() calls on the same
/// — as can happen when a gRPC stream
/// completion and a client cancellation both fire at the same time — must
/// decrement _activeEventSubscriberCount exactly once, never to −1.
/// A negative count permanently blocks future subscribers because
/// AttachEventSubscriber gates on _activeEventSubscriberCount >= effectiveCap.
/// After both racing disposes finish,
/// the count must be exactly 0 and a subsequent single-subscriber attach must
/// succeed.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task EventSubscriberLease_ConcurrentDispose_DecrementsCountExactlyOnce()
{
const int Concurrency = 16;
const int Iterations = 200;
TimeSpan testTimeout = TimeSpan.FromSeconds(10);
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySessionWithEventStreaming(workerClient);
for (int i = 0; i < Iterations; i++)
{
// Attach one subscriber; this increments _activeEventSubscriberCount to 1.
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
// Race Concurrency threads all calling Dispose() on the same lease.
// Only one must actually run DetachEventSubscriber.
using SemaphoreSlim gate = new(0);
Task[] tasks = new Task[Concurrency];
for (int t = 0; t < Concurrency; t++)
{
tasks[t] = Task.Run(async () =>
{
// All threads wait at the gate so they start as simultaneously
// as the scheduler allows, maximising the race window.
await gate.WaitAsync(testTimeout);
lease.Dispose();
});
}
gate.Release(Concurrency);
await Task.WhenAll(tasks).WaitAsync(testTimeout);
// Count must be exactly 0 — not negative — after all disposes.
Assert.Equal(0, session.ActiveEventSubscriberCount);
// Observable contract: a fresh single subscriber must now be attachable
// (i.e., the guard _activeEventSubscriberCount > 0 is false).
IEventSubscriberLease next = session.AttachEventSubscriber(maxSubscribers: 1);
next.Dispose();
Assert.Equal(0, session.ActiveEventSubscriberCount);
}
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// Single-subscriber mode rejects a SECOND concurrent external
/// attach with —
/// the legacy guard is preserved unchanged when multi-subscriber is disabled.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task AttachEventSubscriber_SingleMode_SecondAttachThrowsAlreadyActive()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySessionWithEventStreaming(workerClient);
using IEventSubscriberLease first = session.AttachEventSubscriber(maxSubscribers: 8);
SessionManagerException exception = Assert.Throws(
() => session.AttachEventSubscriber(maxSubscribers: 8));
Assert.Equal(SessionManagerErrorCode.EventSubscriberAlreadyActive, exception.ErrorCode);
Assert.Equal(1, session.ActiveEventSubscriberCount);
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// Multi-subscriber mode allows exactly cap concurrent external
/// subscribers; the (cap+1)-th attach throws
/// .
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task AttachEventSubscriber_MultiMode_AttachesUpToCapThenThrowsLimitReached()
{
const int Cap = 4;
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySessionWithEventStreaming(
workerClient,
allowMultipleEventSubscribers: true);
List leases = [];
for (int i = 0; i < Cap; i++)
{
leases.Add(session.AttachEventSubscriber(maxSubscribers: Cap));
}
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
SessionManagerException exception = Assert.Throws(
() => session.AttachEventSubscriber(maxSubscribers: Cap));
Assert.Equal(SessionManagerErrorCode.EventSubscriberLimitReached, exception.ErrorCode);
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
foreach (IEventSubscriberLease lease in leases)
{
lease.Dispose();
}
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
/// budget: with the dashboard mirror running, the full cap of external subscribers is
/// still attachable.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task AttachEventSubscriber_MultiMode_DashboardMirrorDoesNotConsumeCap()
{
const int Cap = 3;
FakeWorkerClient workerClient = new();
RecordingDashboardEventBroadcaster broadcaster = new();
GatewaySession session = CreateReadySessionWithEventStreaming(
workerClient,
allowMultipleEventSubscribers: true,
dashboardBroadcaster: broadcaster);
// The internal dashboard mirror registered on MarkReady is NOT counted as an external
// subscriber, so the external active count starts at zero.
Assert.Equal(0, session.ActiveEventSubscriberCount);
List leases = [];
for (int i = 0; i < Cap; i++)
{
leases.Add(session.AttachEventSubscriber(maxSubscribers: Cap));
}
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
// The (cap+1)-th still fails: the dashboard mirror did not eat a slot.
SessionManagerException exception = Assert.Throws(
() => session.AttachEventSubscriber(maxSubscribers: Cap));
Assert.Equal(SessionManagerErrorCode.EventSubscriberLimitReached, exception.ErrorCode);
foreach (IEventSubscriberLease lease in leases)
{
lease.Dispose();
}
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// Many concurrent attaches in multi-subscriber mode must never
/// exceed the cap: the count-check-and-increment is atomic under _syncRoot, so
/// exactly cap attaches succeed and the rest throw
/// . The observed
/// count never goes above the cap.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task AttachEventSubscriber_MultiMode_ConcurrentAttaches_NeverExceedCap()
{
const int Cap = 5;
const int Attempts = 32;
TimeSpan testTimeout = TimeSpan.FromSeconds(10);
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySessionWithEventStreaming(
workerClient,
allowMultipleEventSubscribers: true);
using SemaphoreSlim gate = new(0);
int successCount = 0;
int limitReachedCount = 0;
int maxObservedCount = 0;
IEventSubscriberLease?[] leases = new IEventSubscriberLease?[Attempts];
Task[] tasks = new Task[Attempts];
for (int t = 0; t < Attempts; t++)
{
int index = t;
tasks[index] = Task.Run(async () =>
{
await gate.WaitAsync(testTimeout);
try
{
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: Cap);
leases[index] = lease;
Interlocked.Increment(ref successCount);
}
catch (SessionManagerException exception)
when (exception.ErrorCode == SessionManagerErrorCode.EventSubscriberLimitReached)
{
Interlocked.Increment(ref limitReachedCount);
}
int observed = session.ActiveEventSubscriberCount;
int previousMax;
do
{
previousMax = Volatile.Read(ref maxObservedCount);
if (observed <= previousMax)
{
break;
}
}
while (Interlocked.CompareExchange(ref maxObservedCount, observed, previousMax) != previousMax);
});
}
gate.Release(Attempts);
await Task.WhenAll(tasks).WaitAsync(testTimeout);
Assert.Equal(Cap, successCount);
Assert.Equal(Attempts - Cap, limitReachedCount);
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
Assert.True(maxObservedCount <= Cap, $"Observed count {maxObservedCount} exceeded cap {Cap}.");
foreach (IEventSubscriberLease? lease in leases)
{
lease?.Dispose();
}
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
/// double-dispose does not double-free the slot (count integrity preserved).
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task AttachEventSubscriber_MultiMode_DisposeFreesSlotAndDoubleDisposeIsIdempotent()
{
const int Cap = 2;
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySessionWithEventStreaming(
workerClient,
allowMultipleEventSubscribers: true);
IEventSubscriberLease a = session.AttachEventSubscriber(maxSubscribers: Cap);
IEventSubscriberLease b = session.AttachEventSubscriber(maxSubscribers: Cap);
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
// At cap: next attach is rejected.
Assert.Throws(
() => session.AttachEventSubscriber(maxSubscribers: Cap));
// Dispose one — and dispose it twice. The second dispose must not double-free.
a.Dispose();
a.Dispose();
Assert.Equal(1, session.ActiveEventSubscriberCount);
// Exactly one slot is free, so exactly one fresh attach succeeds.
using IEventSubscriberLease c = session.AttachEventSubscriber(maxSubscribers: Cap);
Assert.Equal(Cap, session.ActiveEventSubscriberCount);
Assert.Throws(
() => session.AttachEventSubscriber(maxSubscribers: Cap));
b.Dispose();
await session.CloseAsync("test-done", CancellationToken.None);
await session.DisposeAsync();
}
///
/// With a positive detach-grace, dropping the last external subscriber must
/// RETAIN the session (it stays , not Closed/Faulted)
/// and record a detached timestamp so the lease monitor can age it out later.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_LastSubscriberDrops_RetainsSessionAndRecordsDetachedTimestamp()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30));
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
Assert.Null(session.DetachedAtUtc);
lease.Dispose();
// Retained, not torn down.
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, session.ActiveEventSubscriberCount);
Assert.Equal(clock.GetUtcNow(), session.DetachedAtUtc);
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
///
/// Advancing the clock past the detach-grace window makes the retained,
/// detached session eligible for close ().
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_ClockAdvancesPastWindow_SessionBecomesEligibleForClose()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30));
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
lease.Dispose();
// Just before the window elapses: not yet eligible.
clock.Advance(TimeSpan.FromSeconds(29));
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
// At/after the window: eligible for close.
clock.Advance(TimeSpan.FromSeconds(1));
Assert.True(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
///
/// Re-attaching a subscriber before the window elapses cancels the grace:
/// the detached timestamp clears and a subsequent clock advance does NOT make the
/// session eligible for close.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_ReattachBeforeExpiry_CancelsGrace()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30));
IEventSubscriberLease first = session.AttachEventSubscriber(maxSubscribers: 1);
first.Dispose();
Assert.NotNull(session.DetachedAtUtc);
clock.Advance(TimeSpan.FromSeconds(10));
IEventSubscriberLease second = session.AttachEventSubscriber(maxSubscribers: 1);
// Re-attach cancelled the grace window.
Assert.Null(session.DetachedAtUtc);
// Advancing well past what would have been the window does not make it eligible while
// a subscriber is attached.
clock.Advance(TimeSpan.FromMinutes(5));
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
second.Dispose();
}
///
/// With detach-grace disabled (0), dropping the last subscriber must
/// match today's behavior: the session stays with no
/// detached timestamp and is never eligible for a detach-grace close — it lingers only
/// until its normal lease expires.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_Disabled_MatchesTodaysBehavior()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.Zero);
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
lease.Dispose();
Assert.Equal(SessionState.Ready, session.State);
Assert.Null(session.DetachedAtUtc);
clock.Advance(TimeSpan.FromHours(1));
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
///
/// A FAILED first attach (the distributor never registered a
/// subscriber) must NOT enter the detach-grace window: the catch path's
/// DetachEventSubscriber rolls the reserved slot back to 0 but must not stamp
/// DetachedAtUtc, because the "last subscriber dropped" semantics only apply once
/// a subscriber was successfully registered. A freshly-Ready session whose first attach
/// failed must therefore stay out of grace and never become sweep-eligible on that basis.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_FailedFirstAttach_DoesNotEnterGrace()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
// QueueCapacity = 0 makes the distributor constructor throw ArgumentOutOfRangeException
// inside StartDistributorAndRegister, so the very first AttachEventSubscriber fails after
// it reserved a slot — exercising the catch → DetachEventSubscriber rollback path.
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30),
queueCapacity: 0);
Assert.ThrowsAny(
() => session.AttachEventSubscriber(maxSubscribers: 1));
// The reserved slot was rolled back, but no successful subscriber ever existed, so the
// session must NOT have entered detach-grace.
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, session.ActiveEventSubscriberCount);
Assert.Null(session.DetachedAtUtc);
// And it must never become detach-grace-eligible no matter how far the clock advances.
clock.Advance(TimeSpan.FromHours(1));
Assert.False(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
///
/// The gateway-owned internal dashboard subscriber must NOT keep a session out
/// of detach-grace: with only the dashboard mirror attached (and no external gRPC
/// subscriber), dropping the last external subscriber still enters grace and the
/// window still expires.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task DetachGrace_DashboardMirrorAlone_DoesNotPreventGraceEntry()
{
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
FakeWorkerClient workerClient = new();
RecordingDashboardEventBroadcaster broadcaster = new();
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30),
dashboardBroadcaster: broadcaster);
// The dashboard mirror is the only subscriber (registered internally at MarkReady).
// It is not counted as an external subscriber.
Assert.Equal(0, session.ActiveEventSubscriberCount);
IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
lease.Dispose();
// Entered grace despite the dashboard mirror still being attached.
Assert.NotNull(session.DetachedAtUtc);
clock.Advance(TimeSpan.FromSeconds(30));
Assert.True(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
///
/// Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
/// subscriber has reattached before flipping to Closing. When the grace window has elapsed but
/// a subscriber is attached by the time TryBeginCloseIfExpired runs, it returns false and the
/// session remains Ready.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30));
// Attach then drop to enter detach-grace, then advance past the window.
IDisposable firstSubscriber = session.AttachEventSubscriber(maxSubscribers: 1);
firstSubscriber.Dispose();
clock.Advance(TimeSpan.FromSeconds(31));
DateTimeOffset expiredNow = clock.GetUtcNow();
Assert.True(session.IsDetachGraceExpired(expiredNow)); // sanity: would be closed by sweep
// Simulate a client reconnecting before the sweeper calls TryBeginCloseIfExpired.
// The reattach clears _detachedAtUtc and increments _activeEventSubscriberCount so
// neither expiry condition holds any longer.
using IDisposable reconnected = session.AttachEventSubscriber(maxSubscribers: 1);
Assert.Null(session.DetachedAtUtc);
// TryBeginCloseIfExpired must see the reattach and decline — the session stays Ready.
bool began = session.TryBeginCloseIfExpired(expiredNow, out bool alreadyClosing);
Assert.False(began);
Assert.False(alreadyClosing);
Assert.Equal(SessionState.Ready, session.State);
}
private static GatewaySession CreateReadySessionWithDetachGrace(
IWorkerClient workerClient,
TimeProvider timeProvider,
TimeSpan detachGrace,
IDashboardEventBroadcaster? dashboardBroadcaster = null,
int queueCapacity = 8)
{
GatewaySession session = new(
sessionId: "session-test-detach-grace",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test-detach-grace",
nonce: "nonce",
clientIdentity: "client-1",
ownerKeyId: null,
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: timeProvider.GetUtcNow(),
eventStreaming: new SessionEventStreaming(
new MxAccessGrpcMapper(),
new EventOptions { QueueCapacity = queueCapacity },
NullLogger.Instance,
timeProvider,
new GatewayMetrics(),
dashboardBroadcaster),
detachGrace: detachGrace);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
sessionId: "session-test",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test",
nonce: "nonce",
clientIdentity: "client-1",
ownerKeyId: null,
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
private static GatewaySession CreateReadySessionWithEventStreaming(
IWorkerClient workerClient,
bool allowMultipleEventSubscribers = false,
IDashboardEventBroadcaster? dashboardBroadcaster = null)
{
GatewaySession session = new(
sessionId: "session-test-concurrent",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test-concurrent",
nonce: "nonce",
clientIdentity: "client-1",
ownerKeyId: null,
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow,
eventStreaming: new SessionEventStreaming(
new MxAccessGrpcMapper(),
new EventOptions { QueueCapacity = 8 },
NullLogger.Instance,
TimeProvider.System,
new GatewayMetrics(),
dashboardBroadcaster,
allowMultipleEventSubscribers));
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
///
/// Minimal worker client that parks until the test
/// explicitly releases it. Used to keep
/// stuck between its Closing and Closed writes so the test can
/// observe and act on the intermediate state.
///
private sealed class BlockingShutdownWorkerClient : IWorkerClient
{
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
///
public string SessionId { get; } = "session-test";
///
public int? ProcessId { get; } = 1234;
///
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
///
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// Gets the count of shutdown invocations.
public int ShutdownCount { get; private set; }
/// Gets the count of dispose invocations.
public int DisposeCount { get; private set; }
/// Waits for shutdown to start.
/// A task that represents the asynchronous operation.
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// Releases the shutdown block.
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
///
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public Task InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
///
public async IAsyncEnumerable ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
///
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
_shutdownStarted.TrySetResult();
await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
State = WorkerClientState.Closed;
}
///
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// Releases the fake worker resources and records the invocation.
/// A task that represents the asynchronous operation.
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
///
public string SessionId { get; } = "session-test";
///
public int? ProcessId { get; } = 1234;
///
public WorkerClientState State { get; } = WorkerClientState.Ready;
///
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// Gets the count of dispose invocations.
public int DisposeCount { get; private set; }
///
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public Task InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
///
public async IAsyncEnumerable ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
///
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
///
public void Kill(string reason)
{
}
/// Releases the fake worker resources and records the invocation.
/// A task that represents the asynchronous operation.
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
}