docs(src): add missing XML docs and strip tracking-ID comments

Sweep of 203 source files resolving CommentChecker findings: add
<summary>/<param>/<returns>/<inheritdoc> where missing, and remove
resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN,
Task N) from code comments. Comment/doc-only — no logic changes.
Server+Tests build clean under TreatWarningsAsErrors.
This commit is contained in:
Joseph Doherty
2026-07-07 14:09:49 -04:00
parent 8914472706
commit fca978de07
203 changed files with 1834 additions and 1383 deletions
@@ -43,6 +43,7 @@ public sealed class ArrayAddressNormalizerTests
}
/// <summary>Verifies null, empty, and whitespace addresses are returned unchanged.</summary>
/// <param name="address">The blank address under test.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -96,10 +97,14 @@ public sealed class ArrayAddressNormalizerTests
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
/// <summary>No-op stub refresh; the fixed <see cref="Current"/> entry never changes.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
/// <summary>No-op stub wait; the fixed <see cref="Current"/> entry is already loaded.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -19,6 +19,7 @@ public sealed class GatewayArrayWriteWiringTests
/// A bare array <c>AddItem</c> address is normalized to its writable array form on the wire,
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
@@ -71,6 +72,7 @@ public sealed class GatewayArrayWriteWiringTests
}
/// <summary>A bare scalar <c>AddItem</c> address is forwarded unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItem_ScalarAddress_ForwardedUnchanged()
{
@@ -99,6 +101,7 @@ public sealed class GatewayArrayWriteWiringTests
/// A sparse-array <see cref="WriteCommand"/> value is expanded to a full, default-filled
/// <see cref="MxArray"/> before reaching the worker; no sparse value is ever forwarded.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write_SparseArrayValue_ExpandedBeforeReachingWorker()
{
@@ -145,6 +148,7 @@ public sealed class GatewayArrayWriteWiringTests
/// A bare array <c>AddItem2</c> address is normalized to its writable array form on the wire,
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItem2_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
@@ -199,6 +203,7 @@ public sealed class GatewayArrayWriteWiringTests
/// default-filled <see cref="MxArray"/> before reaching the worker; no sparse value is ever
/// forwarded inside a bulk write.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
@@ -253,6 +258,7 @@ public sealed class GatewayArrayWriteWiringTests
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
/// address in the <see cref="SessionItemRegistration"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
@@ -329,6 +335,7 @@ public sealed class GatewayArrayWriteWiringTests
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
{
@@ -376,6 +383,7 @@ public sealed class GatewayArrayWriteWiringTests
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
@@ -424,6 +432,7 @@ public sealed class GatewayArrayWriteWiringTests
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
@@ -471,6 +480,7 @@ public sealed class GatewayArrayWriteWiringTests
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
@@ -524,6 +534,7 @@ public sealed class GatewayArrayWriteWiringTests
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
@@ -578,6 +589,7 @@ public sealed class GatewayArrayWriteWiringTests
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
@@ -630,6 +642,7 @@ public sealed class GatewayArrayWriteWiringTests
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
@@ -745,10 +758,14 @@ public sealed class GatewayArrayWriteWiringTests
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always ready.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
/// <summary>No-op for this stub; the fixed <see cref="Current"/> entry is always considered loaded.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -760,16 +777,16 @@ public sealed class GatewayArrayWriteWiringTests
/// <summary>Gets or sets the reply returned by the next invocation.</summary>
public WorkerCommandReply NextReply { get; set; } = new();
/// <summary>Gets the session identifier.</summary>
/// <inheritdoc />
public string SessionId { get; } = "session-array-write-wiring";
/// <summary>Gets the worker process identifier.</summary>
/// <inheritdoc />
public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
@@ -801,7 +818,8 @@ public sealed class GatewayArrayWriteWiringTests
{
}
/// <inheritdoc />
/// <summary>Disposes the test worker client. No-op — there is no unmanaged state to release.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -14,7 +14,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Task 6 regression tests for the internal dashboard mirror. The dashboard is a
/// Regression tests for the internal dashboard mirror. The dashboard is a
/// first-class subscriber on the session's <see cref="SessionEventDistributor"/>, so it
/// receives session events whether or not a gRPC client is streaming — fixing the
/// "dark feed" where the dashboard only saw events while a gRPC client was actively
@@ -28,9 +28,8 @@ public sealed class GatewaySessionDashboardMirrorTests
/// The KEY bug-fix test: the dashboard broadcaster receives session events even when
/// NO gRPC <c>StreamEvents</c> subscriber is attached. The session is driven to Ready
/// with a fake worker emitting events; only the internal dashboard subscriber exists.
/// Before Task 6 the mirror lived inside the per-RPC gRPC loop, so with no gRPC
/// subscriber the dashboard saw nothing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DashboardMirror_ReceivesEvents_WithNoGrpcSubscriber()
{
@@ -59,6 +58,7 @@ public sealed class GatewaySessionDashboardMirrorTests
/// gRPC path is no longer the dashboard's source — both read independent leases fed by
/// the single distributor pump.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DashboardMirror_AndGrpcSubscriber_BothReceiveEvents()
{
@@ -108,11 +108,12 @@ public sealed class GatewaySessionDashboardMirrorTests
}
/// <summary>
/// Task 4 hazard guard: starting the pump at Ready with a fast-completing worker stream
/// Hazard guard: starting the pump at Ready with a fast-completing worker stream
/// and zero subscribers used to drain into nothing and leave a later subscriber hanging.
/// Now the dashboard subscriber is registered BEFORE the pump starts, so even a worker
/// stream that completes immediately delivers every event to the dashboard with no hang.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DashboardMirror_FastCompletingWorkerStream_DeliversAllEventsWithoutHang()
{
@@ -134,6 +135,7 @@ public sealed class GatewaySessionDashboardMirrorTests
/// The dashboard Publish must be never-throw at the seam too: a throwing broadcaster
/// must not fault the session or stop the mirror from continuing past the failure.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DashboardMirror_WhenBroadcasterThrows_DoesNotFaultSessionAndKeepsMirroring()
{
@@ -155,6 +157,7 @@ public sealed class GatewaySessionDashboardMirrorTests
/// The internal dashboard subscriber must NOT count against the single-subscriber
/// guard: a gRPC subscriber can still attach while the dashboard mirror is running.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DashboardMirror_DoesNotCountAgainstSingleSubscriberGuard()
{
@@ -239,8 +242,10 @@ public sealed class GatewaySessionDashboardMirrorTests
{
private int _publishAttempts;
/// <summary>Number of times <see cref="Publish"/> has been invoked.</summary>
public int PublishAttempts => Volatile.Read(ref _publishAttempts);
/// <inheritdoc />
public void Publish(string sessionId, MxEvent mxEvent)
{
Interlocked.Increment(ref _publishAttempts);
@@ -250,49 +255,59 @@ public sealed class GatewaySessionDashboardMirrorTests
private sealed class SingleSessionManager(GatewaySession session) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => Task.FromResult(session);
/// <inheritdoc />
public bool TryGetSession(string sessionId, out GatewaySession gatewaySession)
{
gatewaySession = session;
return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Events yielded by <see cref="ReadEventsAsync"/>, in order.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>When <see langword="true"/>, <see cref="ReadEventsAsync"/> completes after yielding <see cref="Events"/> instead of hanging.</summary>
public bool CompleteAfterConfiguredEvents { get; set; }
// Gate that holds the event stream before it yields anything. Released by default, so
@@ -308,26 +323,35 @@ public sealed class GatewaySessionDashboardMirrorTests
return gate;
}
/// <summary>Re-arms the release gate so <see cref="ReadEventsAsync"/> blocks before yielding events until <see cref="ReleaseEvents"/> is called.</summary>
public void HoldEventsUntilReleased() =>
_releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Releases the gate set by <see cref="HoldEventsUntilReleased"/>, letting <see cref="ReadEventsAsync"/> proceed.</summary>
public void ReleaseEvents() => _releaseGate.TrySetResult();
/// <inheritdoc />
public string SessionId { get; } = "session-dashboard-mirror";
/// <inheritdoc />
public int? ProcessId { get; } = 1234;
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -348,12 +372,16 @@ public sealed class GatewaySessionDashboardMirrorTests
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <summary>No-op dispose; the fake worker client owns no resources to release.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -13,21 +13,21 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
/// Server-015 and Server-016 audited the split lock discipline between
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>,
/// covering the split lock discipline between <c>_syncRoot</c> (state transitions)
/// and <c>_closeLock</c> (close serialization) and the gated <c>DisposeAsync</c>.
/// </summary>
public sealed class GatewaySessionTests
{
/// <summary>
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
/// A <c>TransitionTo(Ready)</c> issued after
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
/// window the audit identified.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
{
@@ -54,12 +54,13 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
/// Once <see cref="GatewaySession.CloseAsync"/> finishes,
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
/// session out of <see cref="SessionState.Closed"/> either — the close path's
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
/// machine uses, so the existing "Closed is terminal" invariant holds.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
{
@@ -76,7 +77,7 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
/// A <see cref="GatewaySession.MarkFaulted"/> issued
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
/// <c>Closing</c> and <c>Closed</c> writes must not break the close path's
/// terminal contract: the in-flight close runs to <c>Closed</c>, the fault
@@ -86,6 +87,7 @@ public sealed class GatewaySessionTests
/// Faulted" — this test pins the resolved end state so a future tightening
/// of <c>MarkFaulted</c> cannot silently regress it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
{
@@ -113,11 +115,12 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
/// <see cref="GatewaySession.DisposeAsync"/> must wait
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
{
@@ -149,6 +152,7 @@ public sealed class GatewaySessionTests
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
/// rather than propagating it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
{
@@ -164,7 +168,7 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Issue-1 regression. Concurrent <c>Dispose()</c> calls on the same
/// Concurrent <c>Dispose()</c> calls on the same
/// <see cref="IEventSubscriberLease"/> — as can happen when a gRPC stream
/// completion and a client cancellation both fire at the same time — must
/// decrement <c>_activeEventSubscriberCount</c> exactly once, never to 1.
@@ -174,6 +178,7 @@ public sealed class GatewaySessionTests
/// the count must be exactly 0 and a subsequent single-subscriber attach must
/// succeed.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EventSubscriberLease_ConcurrentDispose_DecrementsCountExactlyOnce()
{
@@ -222,10 +227,11 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 8 regression. Single-subscriber mode rejects a SECOND concurrent external
/// Single-subscriber mode rejects a SECOND concurrent external
/// attach with <see cref="SessionManagerErrorCode.EventSubscriberAlreadyActive"/> —
/// the legacy guard is preserved unchanged when multi-subscriber is disabled.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachEventSubscriber_SingleMode_SecondAttachThrowsAlreadyActive()
{
@@ -244,10 +250,11 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 8. Multi-subscriber mode allows exactly <c>cap</c> concurrent external
/// Multi-subscriber mode allows exactly <c>cap</c> concurrent external
/// subscribers; the (cap+1)-th attach throws
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachEventSubscriber_MultiMode_AttachesUpToCapThenThrowsLimitReached()
{
@@ -280,10 +287,11 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 8. The gateway-owned INTERNAL dashboard subscriber must NOT consume cap
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachEventSubscriber_MultiMode_DashboardMirrorDoesNotConsumeCap()
{
@@ -322,12 +330,13 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 8 concurrency. Many concurrent attaches in multi-subscriber mode must never
/// Many concurrent attaches in multi-subscriber mode must never
/// exceed the cap: the count-check-and-increment is atomic under <c>_syncRoot</c>, so
/// exactly <c>cap</c> attaches succeed and the rest throw
/// <see cref="SessionManagerErrorCode.EventSubscriberLimitReached"/>. The observed
/// count never goes above the cap.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachEventSubscriber_MultiMode_ConcurrentAttaches_NeverExceedCap()
{
@@ -396,9 +405,10 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 8. Disposing a subscriber frees a cap slot so a fresh attach succeeds, and a
/// 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).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachEventSubscriber_MultiMode_DisposeFreesSlotAndDoubleDisposeIsIdempotent()
{
@@ -434,10 +444,11 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. With a positive detach-grace, dropping the last external subscriber must
/// With a positive detach-grace, dropping the last external subscriber must
/// RETAIN the session (it stays <see cref="SessionState.Ready"/>, not Closed/Faulted)
/// and record a detached timestamp so the lease monitor can age it out later.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_LastSubscriberDrops_RetainsSessionAndRecordsDetachedTimestamp()
{
@@ -461,9 +472,10 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. Advancing the clock past the detach-grace window makes the retained,
/// Advancing the clock past the detach-grace window makes the retained,
/// detached session eligible for close (<see cref="GatewaySession.IsDetachGraceExpired"/>).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_ClockAdvancesPastWindow_SessionBecomesEligibleForClose()
{
@@ -487,10 +499,11 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. Re-attaching a subscriber before the window elapses cancels the grace:
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_ReattachBeforeExpiry_CancelsGrace()
{
@@ -520,11 +533,12 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. With detach-grace disabled (<c>0</c>), dropping the last subscriber must
/// With detach-grace disabled (<c>0</c>), dropping the last subscriber must
/// match today's behavior: the session stays <see cref="SessionState.Ready"/> with no
/// detached timestamp and is never eligible for a detach-grace close — it lingers only
/// until its normal lease expires.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_Disabled_MatchesTodaysBehavior()
{
@@ -546,13 +560,14 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Server-055 regression. A FAILED first attach (the distributor never registered a
/// A FAILED first attach (the distributor never registered a
/// subscriber) must NOT enter the detach-grace window: the catch path's
/// <c>DetachEventSubscriber</c> rolls the reserved slot back to 0 but must not stamp
/// <c>DetachedAtUtc</c>, 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_FailedFirstAttach_DoesNotEnterGrace()
{
@@ -583,11 +598,12 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. The gateway-owned internal dashboard subscriber must NOT keep a session out
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DetachGrace_DashboardMirrorAlone_DoesNotPreventGraceEntry()
{
@@ -615,11 +631,12 @@ public sealed class GatewaySessionTests
}
/// <summary>
/// Task 11. Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose()
{
@@ -749,16 +766,16 @@ public sealed class GatewaySessionTests
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the session identifier.</summary>
/// <inheritdoc />
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
/// <inheritdoc />
public int? ProcessId { get; } = 1234;
/// <summary>Gets or sets the worker client state.</summary>
/// <inheritdoc />
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of shutdown invocations.</summary>
@@ -768,6 +785,7 @@ public sealed class GatewaySessionTests
public int DisposeCount { get; private set; }
/// <summary>Waits for shutdown to start.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -811,7 +829,8 @@ public sealed class GatewaySessionTests
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
/// <summary>Releases the fake worker resources and records the invocation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -821,16 +840,16 @@ public sealed class GatewaySessionTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session identifier.</summary>
/// <inheritdoc />
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
/// <inheritdoc />
public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
/// <inheritdoc />
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of dispose invocations.</summary>
@@ -861,7 +880,8 @@ public sealed class GatewaySessionTests
{
}
/// <inheritdoc />
/// <summary>Releases the fake worker resources and records the invocation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -16,6 +16,8 @@ public sealed class SessionEventDistributorTests
{
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
/// <summary>Two subscribers registered on the same distributor both receive every fanned event, in order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TwoSubscribers_BothReceiveFannedEventsInOrder()
{
@@ -40,6 +42,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(2ul, b2.WorkerSequence);
}
/// <summary>Disposing one subscriber's lease stops delivery to it while the other subscriber keeps receiving events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposingOneLease_StopsItsDelivery_OtherKeepsReceiving()
{
@@ -65,6 +69,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(2ul, b2.WorkerSequence);
}
/// <summary>A subscriber registered after the pump has started only receives events emitted after its registration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscriberRegisteredAfterStart_ReceivesEventsEmittedAfterRegistration()
{
@@ -84,6 +90,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(2ul, b.WorkerSequence);
}
/// <summary>Disposing the distributor completes every subscriber channel and stops the pump.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposingDistributor_CompletesAllSubscriberChannels_AndStopsPump()
{
@@ -101,6 +109,8 @@ public sealed class SessionEventDistributorTests
await AssertCompletedAsync(leaseB.Reader);
}
/// <summary>Calling <c>Register</c> after the distributor has been disposed throws <see cref="ObjectDisposedException"/>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Register_AfterDispose_ThrowsObjectDisposedException()
{
@@ -113,6 +123,11 @@ public sealed class SessionEventDistributorTests
Assert.Throws<ObjectDisposedException>(() => distributor.Register());
}
/// <summary>
/// Pins the nested-lock disposal behavior in <c>RegisterWithReplay</c>: calling it after the
/// distributor has been disposed throws <see cref="ObjectDisposedException"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RegisterWithReplay_AfterDispose_ThrowsObjectDisposedException()
{
@@ -134,6 +149,8 @@ public sealed class SessionEventDistributorTests
out _));
}
/// <summary>When retained events exceed the replay buffer's capacity, the oldest entries are evicted first and the replay reports a gap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_OverCapacity_EvictsOldestFirst_AndReportsGap()
{
@@ -168,6 +185,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
}
/// <summary>Requesting replay from a sequence still inside the retained window returns only the newer events, with no gap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_WithinRetainedWindow_ReturnsNewerEvents_NoGap()
{
@@ -194,6 +213,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(new ulong[] { 3, 4, 5 }, replay.Select(e => e.WorkerSequence));
}
/// <summary>Retained replay entries older than the retention window are evicted once that window elapses.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_AgedEntries_AreEvictedAfterRetentionElapses()
{
@@ -228,6 +249,8 @@ public sealed class SessionEventDistributorTests
Assert.True(gap);
}
/// <summary>Requesting replay from a sequence newer than everything retained returns an empty list with no gap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_AfterSequenceNewerThanAllRetained_ReturnsEmpty_NoGap()
{
@@ -254,6 +277,11 @@ public sealed class SessionEventDistributorTests
Assert.Empty(replay);
}
/// <summary>
/// With replay buffering disabled (capacity 0), a caller behind the highest-seen sequence
/// is told there is a gap and gets no replayed events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_Capacity0_AfterSequenceBelowHighestSeen_ReportsGap_NoEvents()
{
@@ -281,6 +309,8 @@ public sealed class SessionEventDistributorTests
Assert.Empty(replay);
}
/// <summary>With replay buffering disabled (capacity 0), a caller already caught up sees no gap and no events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_Capacity0_AfterSequenceAtOrAboveHighestSeen_NoGap_NoEvents()
{
@@ -307,6 +337,8 @@ public sealed class SessionEventDistributorTests
Assert.Empty(replay);
}
/// <summary>When no events have ever been seen, any requested sequence reports no gap and no events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_NoEventsSeen_AnyAfterSequence_NoGap_NoEvents()
{
@@ -325,6 +357,11 @@ public sealed class SessionEventDistributorTests
Assert.Empty(replay);
}
/// <summary>
/// Requesting replay from <see cref="ulong.MaxValue"/> with retained events present does not
/// falsely report a gap from the wrap-around and yields no new events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayBuffer_AfterSequenceMaxValue_WithRetainedEvents_NoGap_NoNewEvents()
{
@@ -349,12 +386,15 @@ public sealed class SessionEventDistributorTests
Assert.Empty(replay);
}
/// <summary>
/// Per-subscriber backpressure isolation: one subscriber stops reading and overflows its
/// own tiny channel; it is disconnected with an <c>EventQueueOverflow</c> fault while a
/// second, healthy subscriber keeps receiving and the pump keeps pumping.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SlowSubscriberOverflow_DisconnectsOnlyThatSubscriber_PumpAndOtherKeepRunning()
{
// Per-subscriber backpressure isolation (Task 5): one subscriber stops reading and
// overflows its own tiny channel; it is disconnected with an EventQueueOverflow fault
// while a second, healthy subscriber keeps receiving and the pump keeps pumping.
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
int overflowCalls = 0;
// Separate fields for the bool value and the "set" flag so both can use
@@ -380,7 +420,7 @@ public sealed class SessionEventDistributorTests
singleSubscriberMode: false);
await distributor.StartAsync(CancellationToken.None);
// Slow subscriber: registered but never read, so its capacity-2 channel fills.
// Slow subscriber: registered but never read, so its channel (capacity 2) fills.
using IEventSubscriberLease slow = distributor.Register();
// Healthy subscriber: drains promptly throughout.
using IEventSubscriberLease healthy = distributor.Register();
@@ -399,7 +439,7 @@ public sealed class SessionEventDistributorTests
async () => await DrainUntilFaultAsync(slow.Reader));
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, fault.ErrorCode);
// Multi-subscriber mode, so isOnlySubscriber is always false (Task 8 mode-gating).
// Multi-subscriber mode, so isOnlySubscriber is always false (mode-gating).
// Use Interlocked.Read / Volatile.Read so the test-thread reads are ordered after the
// pump-thread writes, avoiding a data race by the C# memory model.
Assert.Equal(1, Volatile.Read(ref overflowCalls));
@@ -413,16 +453,19 @@ public sealed class SessionEventDistributorTests
Assert.Equal(11ul, afterOverflow.WorkerSequence);
}
/// <summary>
/// Distributor-level pin for "FailFast with multiple subscribers degrades to
/// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
/// always false (mode-gating), so a FailFast-wired handler must NOT fault the session.
/// This test drives the distributor directly (without <c>GatewaySession</c>) in
/// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
/// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
/// subscriber keeps receiving, and (c) the pump keeps running.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SlowSubscriberOverflow_WithMultipleSubscribers_HandlerSeesIsOnlySubscriberFalse_OtherKeepsReceiving()
{
// Distributor-level pin for "FailFast with multiple subscribers degrades to
// disconnect-only (no session fault)": in multi-subscriber mode isOnlySubscriber is
// always false (Task 8 mode-gating), so a FailFast-wired handler must NOT fault the
// session. This test drives the distributor directly (without GatewaySession) in
// multi-subscriber mode with two subscribers and a FailFast-style overflow handler
// seam, overflows the slow one, and asserts (a) isOnlySubscriber==false, (b) the other
// subscriber keeps receiving, and (c) the pump keeps running.
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
bool handlerFiredWithFalse = false;
bool sessionFaultWouldBeCalled = false; // tracks if a FailFast path would fault
@@ -450,7 +493,7 @@ public sealed class SessionEventDistributorTests
singleSubscriberMode: false);
await distributor.StartAsync(CancellationToken.None);
// Slow subscriber: never reads, so capacity-2 channel overflows quickly.
// Slow subscriber: never reads, so its channel (capacity 2) overflows quickly.
using IEventSubscriberLease slow = distributor.Register();
// Healthy subscriber: drains every event promptly.
using IEventSubscriberLease healthy = distributor.Register();
@@ -478,14 +521,17 @@ public sealed class SessionEventDistributorTests
Assert.Equal(11ul, afterOverflow.WorkerSequence);
}
/// <summary>
/// Verifies that <c>CountExternalSubscribers()</c> excludes the internal dashboard
/// subscriber, so a FailFast policy would NOT fault the session even when the internal
/// subscriber is the ONLY registered subscriber. The overflow handler receives
/// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
/// and is therefore excluded from the external-subscriber count.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse_ProvingCountExcludesInternal()
{
// Issue 3: verifies that CountExternalSubscribers() excludes the internal dashboard
// subscriber, so a FailFast policy would NOT fault the session even when the internal
// subscriber is the ONLY registered subscriber. The overflow handler receives
// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
// and is therefore excluded from the external-subscriber count.
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
int observedIsOnlySubscriberSet = 0;
bool observedIsOnlySubscriberValue = false;
@@ -509,7 +555,7 @@ public sealed class SessionEventDistributorTests
// Register ONLY an internal subscriber — no external subscriber is attached.
using IEventSubscriberLease internalLease = distributor.Register(isInternal: true);
// Push enough events to overflow the capacity-2 internal subscriber channel.
// Push enough events to overflow the internal subscriber channel (capacity 2).
for (ulong sequence = 1; sequence <= 10; sequence++)
{
source.Writer.TryWrite(Event(sequence));
@@ -540,13 +586,16 @@ public sealed class SessionEventDistributorTests
"isInternal must be true for a subscriber registered with isInternal: true.");
}
/// <summary>
/// Mode-gating: in single-subscriber mode a lone external subscriber that overflows
/// reports isOnlySubscriber==true, so the legacy FailFast session-fault path is
/// preserved. The decision is gated on the fixed session mode, NOT a live count, so it
/// is race-free.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SingleSubscriberMode_LoneExternalOverflow_HandlerSeesIsOnlySubscriberTrue()
{
// Task 8 mode-gating: in single-subscriber mode a lone external subscriber that
// overflows reports isOnlySubscriber==true, so the legacy FailFast session-fault path
// is preserved. The decision is gated on the fixed session mode, NOT a live count, so
// it is race-free.
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
int observedSet = 0;
bool observedValue = false;
@@ -593,6 +642,11 @@ public sealed class SessionEventDistributorTests
"isOnlySubscriber must be true for a lone external subscriber in single-subscriber mode.");
}
/// <summary>
/// Registering with replay from a sequence still inside the retained window returns the
/// newer retained events with no gap, then continues to deliver live events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RegisterWithReplay_WithinRetainedWindow_ReturnsNewerEvents_NoGap_ThenLive()
{
@@ -631,6 +685,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(6ul, live.WorkerSequence);
}
/// <summary>Registering with replay from a sequence below the oldest retained event reports a gap along with the oldest available sequence.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RegisterWithReplay_BelowOldestRetained_ReportsGap_AndOldestAvailable()
{
@@ -662,6 +718,8 @@ public sealed class SessionEventDistributorTests
Assert.Equal(5ul, liveResume);
}
/// <summary>When nothing retained is newer than the requested sequence, the live resume watermark equals the requested sequence and no gap is reported.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RegisterWithReplay_NothingRetainedNewer_LiveResumeEqualsAfterSequence_NoGap()
{
@@ -733,6 +791,7 @@ public sealed class SessionEventDistributorTests
/// <c>GatewaySessionDashboardMirrorTests</c>, where a gRPC subscriber attached after a
/// fast-completing worker stream had already drained.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Register_AfterSourceCompletes_CompletesLateSubscriberInsteadOfHanging()
{
@@ -9,7 +9,7 @@ using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Tests-013: per-method gateway-side coverage for every
/// Per-method gateway-side coverage for every
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
@@ -21,6 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class SessionManagerBulkTests
{
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{
@@ -50,6 +51,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddItemBulkAsync_PropagatesCancellation()
{
@@ -63,6 +65,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{
@@ -90,6 +93,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation()
{
@@ -103,6 +107,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{
@@ -128,6 +133,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation()
{
@@ -141,6 +147,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{
@@ -167,6 +174,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{
@@ -180,6 +188,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{
@@ -208,6 +217,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation()
{
@@ -221,6 +231,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{
@@ -246,6 +257,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{
@@ -259,6 +271,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{
@@ -292,6 +305,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteBulkAsync_PropagatesCancellation()
{
@@ -308,6 +322,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{
@@ -351,6 +366,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Write2BulkAsync_PropagatesCancellation()
{
@@ -376,6 +392,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{
@@ -427,6 +444,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{
@@ -452,7 +470,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>
/// Tests-022: Pin mid-flight cancellation behaviour for at least one bulk
/// Pin mid-flight cancellation behaviour for at least one bulk
/// path. Unlike the pre-cancel <c>WriteSecuredBulkAsync_PropagatesCancellation</c>
/// above, this fake's <see cref="MidFlightBulkWorkerClient.InvokeAsync"/>
/// returns a <see cref="TaskCompletionSource"/>-backed task that does NOT
@@ -463,6 +481,7 @@ public sealed class SessionManagerBulkTests
/// client closing its stream would hit, which the pre-cancel pattern can't
/// exercise.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredBulkAsync_WhenCancelledMidFlight_ThrowsOperationCanceledForRequestToken()
{
@@ -499,6 +518,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{
@@ -547,6 +567,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{
@@ -573,6 +594,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{
@@ -619,6 +641,7 @@ public sealed class SessionManagerBulkTests
}
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadBulkAsync_PropagatesCancellation()
{
@@ -779,12 +802,13 @@ public sealed class SessionManagerBulkTests
/// <inheritdoc />
public void Kill(string reason) => State = WorkerClientState.Faulted;
/// <inheritdoc />
/// <summary>Disposes the fake worker client; this fake has no resources to release.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// Mid-flight cancellation fake for Tests-022.
/// Mid-flight cancellation fake worker client.
/// <see cref="InvokeAsync"/> signals <see cref="InvokeStarted"/>, registers
/// a cancellation continuation on the caller's <see cref="CancellationToken"/>,
/// and parks on a <see cref="TaskCompletionSource{TResult}"/> that completes
@@ -858,7 +882,8 @@ public sealed class SessionManagerBulkTests
_invokeCompletion.TrySetCanceled();
}
/// <inheritdoc />
/// <summary>Disposes the fake worker client, cancelling any in-flight invoke.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync()
{
_invokeCompletion.TrySetCanceled();
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class SessionManagerTests
{
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
{
@@ -37,6 +38,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that a session opened by an authenticated caller records that caller's API key id in OwnerKeyId.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithOwnerKeyId_RecordsOwnerKeyIdOnSession()
{
@@ -52,6 +54,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that a session opened without an owner key id records null in OwnerKeyId.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithNullOwnerKeyId_RecordsNullOwnerKeyIdOnSession()
{
@@ -67,6 +70,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_SetsInitialDefaultLease()
{
@@ -83,6 +87,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that session generation creates client correlation ID from client name and session ID.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -99,6 +104,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
{
@@ -114,6 +120,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
{
@@ -131,6 +138,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that invoking a command on a ready session refreshes its lease expiry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
{
@@ -159,6 +167,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -204,6 +213,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that gateway session write bulk forwards one bulk command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -269,6 +279,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that gateway session read bulk forwards one bulk command and returns results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -319,6 +330,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
{
@@ -338,12 +350,13 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Server-030 regression: when the gateway-side <c>SessionState</c> is
/// When the gateway-side <c>SessionState</c> is
/// <c>Ready</c> but the worker client's own state is not, the diagnostic
/// must surface both states so the mismatch is actionable instead of
/// producing a self-contradictory "Session ... is not ready. Current
/// state is Ready." message.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates()
{
@@ -372,6 +385,7 @@ public sealed class SessionManagerTests
/// <c>Handshaking</c> but flips to <c>Ready</c> within the timeout window must let the
/// command through rather than fail fast.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds()
{
@@ -402,6 +416,7 @@ public sealed class SessionManagerTests
/// A terminal worker state (<c>Faulted</c>) must fail fast even with a positive
/// worker-ready wait timeout, surfacing both states without burning the timeout.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenWorkerFaulted_FailsFastWithBothStates()
{
@@ -431,6 +446,7 @@ public sealed class SessionManagerTests
/// When the worker stays transiently not-ready for the whole (small) timeout window,
/// the invoke fails after roughly the timeout with both states surfaced.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenTimeoutElapsesStillNotReady_FailsWithBothStates()
{
@@ -460,6 +476,7 @@ public sealed class SessionManagerTests
/// Pins the default (timeout == 0) behavior: a transiently <c>Handshaking</c> worker
/// fails fast immediately, byte-for-byte like the original fail-fast path.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeAsync_WhenTimeoutZero_FailsFastUnchanged()
{
@@ -486,6 +503,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that closing a session removes it from the registry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_RemovesClosedSession()
{
@@ -507,6 +525,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
{
@@ -528,6 +547,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
{
@@ -567,15 +587,13 @@ public sealed class SessionManagerTests
Assert.Equal(1, failingWorkerClient.KillCount);
Assert.Equal(1, failingWorkerClient.DisposeCount);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
// Server-046: a close-that-failed now accounts as SessionClosed (counter += 1) rather
// than SessionRemoved (gauge -= 1, counter unchanged). The session is being removed
// from the registry on this path, so it must show up in the closed count.
Assert.Equal(1, snapshot.SessionsClosed);
Assert.False(snapshot.EventsBySession.ContainsKey(firstSession.SessionId));
Assert.Equal(1, snapshot.OpenSessions);
}
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
{
@@ -626,10 +644,8 @@ public sealed class SessionManagerTests
/// <summary>
/// Verifies that killing a worker removes the session from the registry without calling shutdown.
/// Tests-028: also pins the <c>reason</c> argument propagating through
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
/// <c>IWorkerClient.Kill(reason)</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_KillsWorkerAndRemovesSession()
{
@@ -651,11 +667,12 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Tests-028: <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
/// <see cref="SessionManager.KillWorkerAsync"/> guards its <c>reason</c> argument with
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/>. A blank or whitespace reason must throw
/// <see cref="ArgumentException"/> before any session lookup or worker call runs.
/// </summary>
/// <param name="blankReason">A blank or whitespace reason string.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -674,10 +691,11 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Tests-028: <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/> also rejects null.
/// <see cref="Theory"/> with <see cref="InlineDataAttribute"/> cannot carry <c>null</c> for a
/// non-nullable string parameter on .NET 10, so the null case is its own fact.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_WithNullReason_ThrowsArgumentNullException()
{
@@ -693,6 +711,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that killing the worker for an unknown session raises SessionNotFound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound()
{
@@ -705,10 +724,10 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Regression for Server-044: when <c>session.KillWorker</c> throws, the catch path must still
/// decrement <c>mxgateway.sessions.open</c> (parity with the Server-006 fix in
/// <c>OpenSessionAsync</c>). Without the fix the gauge leaks one open session per failed kill.
/// When <c>session.KillWorker</c> throws, the catch path must still
/// decrement <c>mxgateway.sessions.open</c>. Without the fix the gauge leaks one open session per failed kill.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_WhenSessionKillThrows_DecrementsOpenSessionGauge()
{
@@ -738,10 +757,11 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Regression for Server-045 / Server-048: concurrent kills on the same session must not
/// Concurrent kills on the same session must not
/// double-increment <c>mxgateway.sessions.closed</c>. The first kill wins, the second
/// observes <c>wasClosed == true</c> (or a missing session after removal) and short-circuits.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KillWorkerAsync_ConcurrentCallsOnSameSession_CountClosedExactlyOnce()
{
@@ -777,12 +797,13 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Regression for Server-046: <c>ShutdownAsync</c>'s graceful-close fallback (which calls
/// <c>ShutdownAsync</c>'s graceful-close fallback (which calls
/// <c>KillWorker</c> + <c>RemoveSessionAsync</c> when <c>CloseSessionCoreAsync</c> throws)
/// must still account a successful close: both the open-session gauge must drop to zero AND
/// the <c>mxgateway.sessions.closed</c> counter must increment. Without the fix, the
/// graceful-close failure path under-counts the closed counter.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShutdownAsync_WhenSessionCloseThrows_StillDecrementsOpenSessionGaugeAndIncrementsClosedCounter()
{
@@ -812,6 +833,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
{
@@ -832,6 +854,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
{
@@ -855,6 +878,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
{
@@ -873,10 +897,11 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Task 11. With detach-grace enabled, a session whose last external subscriber dropped
/// With detach-grace enabled, a session whose last external subscriber dropped
/// and whose detach-grace window has elapsed is closed by the lease sweep exactly like an
/// expired-lease session — even though its normal lease is still far in the future.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_ClosesSessionWhoseDetachGraceWindowExpired()
{
@@ -909,11 +934,12 @@ public sealed class SessionManagerTests
}
/// <summary>
/// Task 11. TOCTOU race: a session whose detach-grace window has expired but that
/// TOCTOU race: a session whose detach-grace window has expired but that
/// reattaches an external subscriber before the sweeper calls CloseSessionCoreAsync is
/// NOT closed — it remains Ready and usable. This validates that TryBeginCloseIfExpired
/// re-checks eligibility atomically so a reconnect that wins the race cancels the close.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses()
{
@@ -950,6 +976,7 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
@@ -1097,16 +1124,16 @@ public sealed class SessionManagerTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session ID for the fake worker client.</summary>
/// <inheritdoc />
public string SessionId { get; init; } = "session-1";
/// <summary>Gets the process ID for the fake worker client.</summary>
/// <inheritdoc />
public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the state of the fake worker client.</summary>
/// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times invoke was called on the fake worker client.</summary>
@@ -1118,13 +1145,7 @@ public sealed class SessionManagerTests
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
public int KillCount { get; private set; }
/// <summary>
/// Gets the last reason argument observed by <see cref="Kill"/>. Tests-028:
/// pins the reason-string propagation through
/// <c>SessionManager.KillWorkerAsync</c> → <c>session.KillWorker(reason)</c> →
/// <c>IWorkerClient.Kill(reason)</c>. Without this, the chain could silently
/// drop or substitute the reason argument and existing tests would still pass.
/// </summary>
/// <summary>Gets the last reason argument observed by <see cref="Kill"/>.</summary>
public string? LastKillReason { get; private set; }
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
@@ -1222,7 +1243,8 @@ public sealed class SessionManagerTests
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
/// <summary>Records that the fake worker client was disposed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -1230,6 +1252,7 @@ public sealed class SessionManagerTests
}
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForShutdownStartAsync()
{
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -21,6 +21,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
/// Awaits every scripted worker task so an unhandled exception fails the owning test
/// instead of surfacing later as an unobserved <see cref="TaskScheduler.UnobservedTaskException"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
foreach (IWorkerTaskLauncher launcher in _launchers)
@@ -30,6 +31,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
}
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
{
@@ -63,6 +65,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
}
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
{
@@ -83,6 +86,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
}
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
{
@@ -168,6 +172,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
/// teardown faults expected when the worker client kills or disposes the worker.
/// </summary>
/// <param name="timeout">Maximum time to wait for the worker task.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ObserveWorkerTaskAsync(TimeSpan timeout);
}
@@ -26,6 +26,7 @@ public sealed class SparseArrayExpanderTests
return new MxValue { SparseArrayValue = sparse };
}
/// <summary>Verifies that expanding an Int32 sparse array fills unset indices with the default and applies the given element.</summary>
[Fact]
public void Expand_Int32_FillsDefaultsAndSetsElement()
{
@@ -43,6 +44,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values);
}
/// <summary>Verifies that expanding a Boolean sparse array with no elements fills every index with the default value <c>false</c>.</summary>
[Fact]
public void Expand_Boolean_EmptyElements_AllDefaultFalse()
{
@@ -54,6 +56,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { false, false, false }, value.ArrayValue.BoolValues.Values);
}
/// <summary>Verifies that expanding a sparse array with a zero total length throws an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_ZeroTotalLength_Throws()
{
@@ -63,6 +66,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that an element index at or beyond the total length throws an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_IndexOutOfRange_Throws()
{
@@ -75,6 +79,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that two sparse elements sharing the same index throw an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_DuplicateIndex_Throws()
{
@@ -88,6 +93,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that an unspecified element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_UnsupportedElementType_Throws()
{
@@ -97,6 +103,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that an element value whose kind does not match the declared element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_ElementValueKindMismatch_Throws()
{
@@ -109,6 +116,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
/// <summary>Verifies that expanding a String sparse array fills unset indices with an empty string default.</summary>
[Fact]
public void Expand_String_FillsEmptyStringDefault()
{
@@ -123,6 +131,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { "a", string.Empty }, value.ArrayValue.StringValues.Values);
}
/// <summary>Verifies that expanding a Time sparse array fills unset indices with the epoch timestamp default.</summary>
[Fact]
public void Expand_Time_FillsEpochDefault()
{
@@ -140,6 +149,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(5, value.ArrayValue.TimestampValues.Values[1].Seconds);
}
/// <summary>Verifies that expanding a Double sparse array fills defaults and applies the given element.</summary>
[Fact]
public void Expand_Double_HappyPath()
{
@@ -154,6 +164,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { 0d, 0d, 1.5 }, value.ArrayValue.DoubleValues.Values);
}
/// <summary>Verifies that expanding a Float sparse array fills defaults and applies the given element.</summary>
[Fact]
public void Expand_Float_HappyPath()
{
@@ -168,6 +179,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { 2.5f, 0f }, value.ArrayValue.FloatValues.Values);
}
/// <summary>Verifies that an Integer sparse array whose element is a 64-bit value expands to the Int64 array representation.</summary>
[Fact]
public void Expand_Int64_WhenElementIsInt64()
{
@@ -182,6 +194,7 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(new[] { 0L, 0L, 9_000_000_000L }, value.ArrayValue.Int64Values.Values);
}
/// <summary>Verifies that expanding a non-sparse-array value is a no-op, leaving the original value unchanged.</summary>
[Fact]
public void Expand_NonSparseValue_NoOps()
{
@@ -193,12 +206,14 @@ public sealed class SparseArrayExpanderTests
Assert.Equal(42, value.Int32Value);
}
/// <summary>Verifies that expanding a null value throws <see cref="ArgumentNullException"/>.</summary>
[Fact]
public void Expand_NullValue_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => SparseArrayExpander.Expand(null!));
}
/// <summary>Verifies that a total length exceeding the maximum supported array length throws an invalid-argument <see cref="RpcException"/>.</summary>
[Fact]
public void Expand_TotalLengthExceedsMaxArrayLength_Throws()
{